feat(donehub): add user self endpoint handling and balance calculation logic
This commit is contained in:
@@ -36,6 +36,18 @@ describe('DoneHubAdapter', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.url === '/api/user/self') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
quota: 20_000_000,
|
||||||
|
used_quota: 30_000_000,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'not found' }));
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
});
|
});
|
||||||
@@ -73,4 +85,12 @@ describe('DoneHubAdapter', () => {
|
|||||||
{ key: 'donehub-token-key', name: 'sys_playground', enabled: true },
|
{ key: 'donehub-token-key', name: 'sys_playground', enabled: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats quota as remaining balance and sums used quota for total', async () => {
|
||||||
|
const adapter = new DoneHubAdapter();
|
||||||
|
const balance = await adapter.getBalance(baseUrl, 'token');
|
||||||
|
expect(balance.balance).toBe(40);
|
||||||
|
expect(balance.used).toBe(60);
|
||||||
|
expect(balance.quota).toBe(100);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OneHubAdapter } from './oneHub.js';
|
import { OneHubAdapter } from './oneHub.js';
|
||||||
import type { CheckinResult } from './base.js';
|
import type { BalanceInfo, CheckinResult } from './base.js';
|
||||||
|
|
||||||
export class DoneHubAdapter extends OneHubAdapter {
|
export class DoneHubAdapter extends OneHubAdapter {
|
||||||
readonly platformName: string = 'done-hub';
|
readonly platformName: string = 'done-hub';
|
||||||
@@ -15,6 +15,23 @@ export class DoneHubAdapter extends OneHubAdapter {
|
|||||||
return { success: false, message: 'checkin endpoint not found' };
|
return { success: false, message: 'checkin endpoint not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DoneHub reports `quota` as remaining balance and `used_quota` as spent amount.
|
||||||
|
// Sum them to get the total quota instead of subtracting used from quota.
|
||||||
|
override async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
|
||||||
|
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
const data = res?.data ?? res;
|
||||||
|
const quotaRemaining = (data?.quota || 0) / 500000;
|
||||||
|
const used = (data?.used_quota || 0) / 500000;
|
||||||
|
const total = quotaRemaining + used;
|
||||||
|
const todayIncome = Number.isFinite(data?.today_income) ? (data.today_income / 500000) : undefined;
|
||||||
|
const todayQuotaConsumption = Number.isFinite(data?.today_quota_consumption)
|
||||||
|
? (data.today_quota_consumption / 500000)
|
||||||
|
: undefined;
|
||||||
|
return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption };
|
||||||
|
}
|
||||||
|
|
||||||
// getModels is inherited from OneHubAdapter which already has /api/available_model fallback.
|
// getModels is inherited from OneHubAdapter which already has /api/available_model fallback.
|
||||||
// No need to override here — OneHub's implementation handles this correctly.
|
// No need to override here — OneHub's implementation handles this correctly.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,59 @@ describe('Sub2ApiAdapter', () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts bearer-prefixed access tokens when verifying session tokens', async () => {
|
||||||
|
await startServer((req, res) => {
|
||||||
|
if (req.url === '/api/v1/auth/me') {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
if (auth !== 'Bearer jwt-token') {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: 401, message: 'unauthorized' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 5.0 },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await adapter.verifyToken(baseUrl, 'Bearer jwt-token');
|
||||||
|
expect(result.tokenType).toBe('session');
|
||||||
|
expect(result.userInfo?.username).toBe('testuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists api keys when access token includes Bearer prefix', async () => {
|
||||||
|
await startServer((req, res) => {
|
||||||
|
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
if (auth !== 'Bearer jwt-token') {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: 401, message: 'unauthorized' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ id: 11, key: 'sk-active', name: 'default', status: 'active' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await adapter.getApiTokens(baseUrl, 'Bearer jwt-token');
|
||||||
|
expect(tokens).toEqual([{ key: 'sk-active', name: 'default', enabled: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('lists api keys from /api/v1/keys and picks active key as default api token', async () => {
|
it('lists api keys from /api/v1/keys and picks active key as default api token', async () => {
|
||||||
await startServer((req, res) => {
|
await startServer((req, res) => {
|
||||||
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
||||||
@@ -266,6 +319,27 @@ describe('Sub2ApiAdapter', () => {
|
|||||||
expect(groups).toEqual(['1', '2']);
|
expect(groups).toEqual(['1', '2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fetches user groups from /api/v1/groups/available', async () => {
|
||||||
|
await startServer((req, res) => {
|
||||||
|
if (req.url === '/api/v1/groups/available') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: [
|
||||||
|
{ id: 5, name: 'basic' },
|
||||||
|
{ id: 6, name: 'pro' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = await adapter.getUserGroups(baseUrl, 'jwt-token');
|
||||||
|
expect(groups).toEqual(['5', '6']);
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to infer groups from /api/v1/keys when group endpoint is unavailable', async () => {
|
it('falls back to infer groups from /api/v1/keys when group endpoint is unavailable', async () => {
|
||||||
await startServer((req, res) => {
|
await startServer((req, res) => {
|
||||||
if (req.url === '/api/v1/groups?page=1&page_size=100') {
|
if (req.url === '/api/v1/groups?page=1&page_size=100') {
|
||||||
|
|||||||
@@ -21,9 +21,19 @@ function normalizeBaseUrl(baseUrl: string): string {
|
|||||||
export class Sub2ApiAdapter extends BasePlatformAdapter {
|
export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||||
readonly platformName = 'sub2api';
|
readonly platformName = 'sub2api';
|
||||||
|
|
||||||
private normalizeTokenKeyForCompare(value?: string | null): string {
|
private stripBearerPrefix(value?: string | null): string {
|
||||||
const trimmed = (value || '').trim();
|
const trimmed = (value || '').trim();
|
||||||
return trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed;
|
if (!trimmed) return '';
|
||||||
|
return trimmed.replace(/^bearer\s+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTokenKeyForCompare(value?: string | null): string {
|
||||||
|
return this.stripBearerPrefix(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAuthHeader(accessToken: string): Record<string, string> {
|
||||||
|
const normalized = this.stripBearerPrefix(accessToken);
|
||||||
|
return { Authorization: `Bearer ${normalized}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseTokenEnabled(status: unknown): boolean {
|
private parseTokenEnabled(status: unknown): boolean {
|
||||||
@@ -141,16 +151,18 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
|
|
||||||
private async listGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
private async listGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||||
const endpoints = [
|
const endpoints = [
|
||||||
|
'/api/v1/groups/available',
|
||||||
'/api/v1/groups?page=1&page_size=100',
|
'/api/v1/groups?page=1&page_size=100',
|
||||||
'/api/v1/groups',
|
'/api/v1/groups',
|
||||||
'/api/v1/group?page=1&page_size=100',
|
'/api/v1/group?page=1&page_size=100',
|
||||||
'/api/v1/group',
|
'/api/v1/group',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const headers = this.buildAuthHeader(accessToken);
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers,
|
||||||
});
|
});
|
||||||
const parsed = (() => {
|
const parsed = (() => {
|
||||||
try {
|
try {
|
||||||
@@ -193,10 +205,11 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
'/api/v1/api-keys?page=1&page_size=100',
|
'/api/v1/api-keys?page=1&page_size=100',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const headers = this.buildAuthHeader(accessToken);
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers,
|
||||||
});
|
});
|
||||||
const parsed = (() => {
|
const parsed = (() => {
|
||||||
try {
|
try {
|
||||||
@@ -235,10 +248,11 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
'/api/v1/api-keys?page=1&page_size=100',
|
'/api/v1/api-keys?page=1&page_size=100',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const headers = this.buildAuthHeader(accessToken);
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers,
|
||||||
});
|
});
|
||||||
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||||
const items = this.parseTokenItems(data);
|
const items = this.parseTokenItems(data);
|
||||||
@@ -384,7 +398,7 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
}> {
|
}> {
|
||||||
const endpoint = '/api/v1/auth/me';
|
const endpoint = '/api/v1/auth/me';
|
||||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: this.buildAuthHeader(accessToken),
|
||||||
});
|
});
|
||||||
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||||
|
|
||||||
@@ -531,11 +545,12 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const endpoints = ['/api/v1/keys', '/api/v1/api-keys'];
|
const endpoints = ['/api/v1/keys', '/api/v1/api-keys'];
|
||||||
|
const headers = this.buildAuthHeader(accessToken);
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
|
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers,
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
this.parseSub2ApiEnvelope<any>(res, endpoint);
|
this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||||
@@ -570,11 +585,12 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
`/api/v1/keys/${tokenId}`,
|
`/api/v1/keys/${tokenId}`,
|
||||||
`/api/v1/api-keys/${tokenId}`,
|
`/api/v1/api-keys/${tokenId}`,
|
||||||
];
|
];
|
||||||
|
const headers = this.buildAuthHeader(accessToken);
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
|
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers,
|
||||||
});
|
});
|
||||||
this.parseSub2ApiEnvelope<any>(res, endpoint);
|
this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user