forked from boos/BoosAPI
feat: add comprehensive tests for token candidates and proxy log path metadata
- Implemented tests for the `/api/models/token-candidates` endpoint to verify model availability and token coverage scenarios. - Added tests for `composeProxyLogMessage` to ensure proper formatting of log messages with downstream and upstream metadata. - Created utility functions for parsing proxy log path metadata and handling missing token hints. - Developed tests for the `Sites` and `TokenRoutes` components to validate UI behavior and interactions, including scrolling and group expansion. - Introduced a custom hook `useAnimatedVisibility` for managing visibility animations in components. - Enhanced the documentation link for site resources.
This commit is contained in:
@@ -41,6 +41,7 @@ export interface ApiTokenInfo {
|
||||
name: string;
|
||||
key: string;
|
||||
enabled?: boolean;
|
||||
tokenGroup?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateApiTokenOptions {
|
||||
@@ -145,7 +146,7 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
||||
async getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]> {
|
||||
const token = await this.getApiToken(baseUrl, accessToken, platformUserId);
|
||||
if (!token) return [];
|
||||
return [{ name: 'default', key: token, enabled: true }];
|
||||
return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }];
|
||||
}
|
||||
|
||||
async createApiToken(
|
||||
|
||||
@@ -176,12 +176,19 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
const key = typeof item?.key === 'string' ? item.key.trim() : '';
|
||||
if (!key) continue;
|
||||
const rawName = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||
const rawGroup = typeof item?.group === 'string'
|
||||
? item.group.trim()
|
||||
: (typeof item?.group_name === 'string'
|
||||
? item.group_name.trim()
|
||||
: (typeof item?.token_group === 'string' ? item.token_group.trim() : ''));
|
||||
const status = typeof item?.status === 'number' ? item.status : undefined;
|
||||
normalized.push({
|
||||
const tokenInfo: ApiTokenInfo = {
|
||||
name: rawName || (index === 0 ? 'default' : `token-${index + 1}`),
|
||||
key,
|
||||
enabled: status === undefined ? true : status === 1,
|
||||
});
|
||||
};
|
||||
if (rawGroup) tokenInfo.tokenGroup = rawGroup;
|
||||
normalized.push(tokenInfo);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -104,12 +104,17 @@ export class OneApiAdapter extends BasePlatformAdapter {
|
||||
const key = typeof item?.key === 'string' ? item.key.trim() : '';
|
||||
if (!key) return null;
|
||||
const rawName = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||
const rawGroup = typeof item?.group === 'string'
|
||||
? item.group.trim()
|
||||
: (typeof item?.token_group === 'string' ? item.token_group.trim() : '');
|
||||
const status = typeof item?.status === 'number' ? item.status : undefined;
|
||||
return {
|
||||
const tokenInfo: ApiTokenInfo = {
|
||||
name: rawName || (index === 0 ? 'default' : `token-${index + 1}`),
|
||||
key,
|
||||
enabled: status === undefined ? true : status === 1,
|
||||
} satisfies ApiTokenInfo;
|
||||
};
|
||||
if (rawGroup) tokenInfo.tokenGroup = rawGroup;
|
||||
return tokenInfo;
|
||||
})
|
||||
.filter((item: ApiTokenInfo | null): item is ApiTokenInfo => !!item);
|
||||
} catch {
|
||||
|
||||
@@ -154,6 +154,46 @@ describe('Sub2ApiAdapter', () => {
|
||||
expect(models).toEqual(['gpt-4o', 'claude-3-opus']);
|
||||
});
|
||||
|
||||
it('fetches models via api key discovered from /api/v1/keys when JWT cannot call /v1/models directly', async () => {
|
||||
await startServer((req, res) => {
|
||||
const auth = req.headers.authorization || '';
|
||||
if (req.url === '/v1/models' && auth === 'Bearer jwt-token') {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 'API_KEY_REQUIRED',
|
||||
message: 'API key is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/keys?page=1&page_size=100' && auth === 'Bearer jwt-token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 1, key: 'sk-sub2-active', name: 'default', status: 'active' },
|
||||
{ id: 2, key: 'sk-sub2-disabled', name: 'old', status: 'inactive' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/v1/models' && auth === 'Bearer sk-sub2-active') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
object: 'list',
|
||||
data: [{ id: 'gpt-4o-mini' }, { id: 'claude-3-5-sonnet' }],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const models = await adapter.getModels(baseUrl, 'jwt-token');
|
||||
expect(models).toEqual(['gpt-4o-mini', 'claude-3-5-sonnet']);
|
||||
});
|
||||
|
||||
it('handles non-zero code as error in /api/v1/auth/me', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/auth/me') {
|
||||
@@ -176,10 +216,152 @@ describe('Sub2ApiAdapter', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('token management returns empty/false', async () => {
|
||||
expect(await adapter.getApiToken('http://localhost', 'token')).toBeNull();
|
||||
expect(await adapter.getApiTokens('http://localhost', 'token')).toEqual([]);
|
||||
expect(await adapter.createApiToken('http://localhost', 'token')).toBe(false);
|
||||
expect(await adapter.deleteApiToken('http://localhost', 'token', 'key')).toBe(false);
|
||||
it('lists api keys from /api/v1/keys and picks active key as default api token', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 10, key: 'sk-disabled', name: 'old', status: 'inactive' },
|
||||
{ id: 11, key: 'sk-active', name: 'default', status: 'active' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const tokens = await adapter.getApiTokens(baseUrl, 'jwt-token');
|
||||
expect(tokens).toEqual([
|
||||
{ key: 'sk-disabled', name: 'old', enabled: false },
|
||||
{ key: 'sk-active', name: 'default', enabled: true },
|
||||
]);
|
||||
expect(await adapter.getApiToken(baseUrl, 'jwt-token')).toBe('sk-active');
|
||||
});
|
||||
|
||||
it('fetches user groups from /api/v1/groups', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/groups?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 1, name: 'default' },
|
||||
{ id: 2, name: 'vip' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const groups = await adapter.getUserGroups(baseUrl, 'jwt-token');
|
||||
expect(groups).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('falls back to infer groups from /api/v1/keys when group endpoint is unavailable', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/groups?page=1&page_size=100') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/groups') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/group?page=1&page_size=100') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/group') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 11, key: 'sk-1', group_id: 7, status: 'active' },
|
||||
{ id: 12, key: 'sk-2', group_id: 7, status: 'inactive' },
|
||||
{ id: 13, key: 'sk-3', group_id: 9, status: 'active' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const groups = await adapter.getUserGroups(baseUrl, 'jwt-token');
|
||||
expect(groups).toEqual(['7', '9']);
|
||||
});
|
||||
|
||||
it('creates api key via /api/v1/keys', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/keys' && req.method === 'POST') {
|
||||
let rawBody = '';
|
||||
req.on('data', (chunk) => { rawBody += chunk; });
|
||||
req.on('end', () => {
|
||||
const body = JSON.parse(rawBody || '{}');
|
||||
expect(body.name).toBe('metapi-e2e');
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
id: 1,
|
||||
key: 'sk-created',
|
||||
name: body.name,
|
||||
},
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const created = await adapter.createApiToken(baseUrl, 'jwt-token', undefined, { name: 'metapi-e2e' });
|
||||
expect(created).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes api key by key value via /api/v1/keys/:id', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 31, key: 'sk-delete-me', name: 'to-delete', status: 'active' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/keys/31' && req.method === 'DELETE') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: { id: 31 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me');
|
||||
expect(deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo } from './base.js';
|
||||
import {
|
||||
ApiTokenInfo,
|
||||
BasePlatformAdapter,
|
||||
CheckinResult,
|
||||
BalanceInfo,
|
||||
CreateApiTokenOptions,
|
||||
UserInfo,
|
||||
} from './base.js';
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
@@ -8,12 +15,268 @@ function normalizeBaseUrl(baseUrl: string): string {
|
||||
* Sub2API adapter.
|
||||
*
|
||||
* Sub2API uses JWT-based auth with endpoints under /api/v1/*.
|
||||
* It does NOT support: login, check-in, token management, or usage stats.
|
||||
* It does NOT support: login or check-in.
|
||||
* Balance is derived from a USD amount returned by /api/v1/auth/me.
|
||||
*/
|
||||
export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
readonly platformName = 'sub2api';
|
||||
|
||||
private normalizeTokenKeyForCompare(value?: string | null): string {
|
||||
const trimmed = (value || '').trim();
|
||||
return trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed;
|
||||
}
|
||||
|
||||
private parseTokenEnabled(status: unknown): boolean {
|
||||
if (typeof status === 'boolean') return status;
|
||||
if (typeof status === 'number') return status === 1;
|
||||
if (typeof status !== 'string') return true;
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
if (['inactive', 'disabled', 'false', '0', 'off'].includes(normalized)) return false;
|
||||
if (['active', 'enabled', 'true', '1', 'on'].includes(normalized)) return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseTokenItems(payload: any): Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }> {
|
||||
const source = payload?.data ?? payload;
|
||||
const rawItems = (() => {
|
||||
if (Array.isArray(source)) return source;
|
||||
if (Array.isArray(source?.items)) return source.items;
|
||||
if (Array.isArray(source?.list)) return source.list;
|
||||
if (Array.isArray(source?.data)) return source.data;
|
||||
return [];
|
||||
})();
|
||||
|
||||
const items: Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }> = [];
|
||||
for (const item of rawItems) {
|
||||
const key = typeof item?.key === 'string' ? item.key.trim() : '';
|
||||
if (!key) continue;
|
||||
const id = Number.parseInt(String(item?.id), 10);
|
||||
if (!Number.isFinite(id) || id <= 0) continue;
|
||||
const name = typeof item?.name === 'string' && item.name.trim()
|
||||
? item.name.trim()
|
||||
: `token-${id}`;
|
||||
const tokenGroup = (() => {
|
||||
const fromNumeric = Number.parseInt(String(item?.group_id ?? item?.groupId ?? ''), 10);
|
||||
if (Number.isFinite(fromNumeric) && fromNumeric > 0) return String(fromNumeric);
|
||||
const fromText = typeof item?.group_name === 'string'
|
||||
? item.group_name.trim()
|
||||
: (typeof item?.group === 'string' ? item.group.trim() : '');
|
||||
return fromText || null;
|
||||
})();
|
||||
items.push({
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
enabled: this.parseTokenEnabled(item?.status),
|
||||
tokenGroup,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private parseGroupItems(payload: any): string[] {
|
||||
const source = payload?.data ?? payload;
|
||||
const rawItems = (() => {
|
||||
if (Array.isArray(source)) return source;
|
||||
if (Array.isArray(source?.items)) return source.items;
|
||||
if (Array.isArray(source?.list)) return source.list;
|
||||
if (Array.isArray(source?.groups)) return source.groups;
|
||||
if (Array.isArray(source?.data)) return source.data;
|
||||
return [];
|
||||
})();
|
||||
|
||||
const groups: string[] = [];
|
||||
for (const item of rawItems) {
|
||||
if (item == null) continue;
|
||||
if (typeof item === 'number' && Number.isFinite(item) && item > 0) {
|
||||
groups.push(String(Math.trunc(item)));
|
||||
continue;
|
||||
}
|
||||
if (typeof item === 'string') {
|
||||
const normalized = item.trim();
|
||||
if (normalized) groups.push(normalized);
|
||||
continue;
|
||||
}
|
||||
if (typeof item !== 'object') continue;
|
||||
|
||||
const numericCandidates = [
|
||||
(item as any).group_id,
|
||||
(item as any).groupId,
|
||||
(item as any).id,
|
||||
(item as any).value,
|
||||
];
|
||||
let picked = '';
|
||||
for (const candidate of numericCandidates) {
|
||||
const parsed = Number.parseInt(String(candidate), 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
picked = String(parsed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (picked) {
|
||||
groups.push(picked);
|
||||
continue;
|
||||
}
|
||||
|
||||
const textCandidates = [
|
||||
(item as any).name,
|
||||
(item as any).group_name,
|
||||
(item as any).groupName,
|
||||
(item as any).title,
|
||||
(item as any).label,
|
||||
(item as any).code,
|
||||
];
|
||||
for (const candidate of textCandidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const normalized = candidate.trim();
|
||||
if (!normalized) continue;
|
||||
groups.push(normalized);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(groups));
|
||||
}
|
||||
|
||||
private async listGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const endpoints = [
|
||||
'/api/v1/groups?page=1&page_size=100',
|
||||
'/api/v1/groups',
|
||||
'/api/v1/group?page=1&page_size=100',
|
||||
'/api/v1/group',
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
})();
|
||||
const groups = this.parseGroupItems(parsed);
|
||||
if (groups.length > 0) return groups;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private parseGroupIdsFromTokenPayload(payload: any): string[] {
|
||||
const source = payload?.data ?? payload;
|
||||
const rawItems = (() => {
|
||||
if (Array.isArray(source)) return source;
|
||||
if (Array.isArray(source?.items)) return source.items;
|
||||
if (Array.isArray(source?.list)) return source.list;
|
||||
if (Array.isArray(source?.data)) return source.data;
|
||||
return [];
|
||||
})();
|
||||
|
||||
const groups: string[] = [];
|
||||
for (const item of rawItems) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const groupId = Number.parseInt(String((item as any).group_id ?? (item as any).groupId ?? ''), 10);
|
||||
if (!Number.isFinite(groupId) || groupId <= 0) continue;
|
||||
groups.push(String(groupId));
|
||||
}
|
||||
return Array.from(new Set(groups));
|
||||
}
|
||||
|
||||
private async inferGroupsFromKeys(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const endpoints = [
|
||||
'/api/v1/keys?page=1&page_size=100',
|
||||
'/api/v1/api-keys?page=1&page_size=100',
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
})();
|
||||
const groups = this.parseGroupIdsFromTokenPayload(parsed);
|
||||
if (groups.length > 0) return groups;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private extractModelIds(payload: any): string[] {
|
||||
const source = payload?.data ?? payload;
|
||||
const rawModels = (() => {
|
||||
if (Array.isArray(source)) return source;
|
||||
if (Array.isArray(source?.items)) return source.items;
|
||||
if (Array.isArray(source?.models)) return source.models;
|
||||
return [];
|
||||
})();
|
||||
|
||||
const models = rawModels
|
||||
.map((item: any) => (typeof item === 'string' ? item : item?.id))
|
||||
.map((value: unknown) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(models));
|
||||
}
|
||||
|
||||
private async listApiKeys(baseUrl: string, accessToken: string): Promise<Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }>> {
|
||||
const endpoints = [
|
||||
'/api/v1/keys?page=1&page_size=100',
|
||||
'/api/v1/api-keys?page=1&page_size=100',
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
const items = this.parseTokenItems(data);
|
||||
if (items.length > 0) return items;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private async fetchModelsByToken(baseUrl: string, token: string): Promise<string[]> {
|
||||
const authToken = this.normalizeTokenKeyForCompare(token);
|
||||
if (!authToken) return [];
|
||||
|
||||
const endpoints = ['/v1/models', '/api/v1/models'];
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
const models = this.extractModelIds(res);
|
||||
if (models.length > 0) return models;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private resolveExpiresInDays(expiredTime?: number): number | undefined {
|
||||
if (!Number.isFinite(expiredTime)) return undefined;
|
||||
const raw = Math.trunc(expiredTime as number);
|
||||
if (raw <= 0) return undefined;
|
||||
const expiresAtMs = raw > 10_000_000_000 ? raw : raw * 1000;
|
||||
const deltaMs = expiresAtMs - Date.now();
|
||||
const days = Math.max(1, Math.ceil(deltaMs / (24 * 60 * 60 * 1000)));
|
||||
return Number.isFinite(days) ? Math.min(days, 3650) : undefined;
|
||||
}
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
const normalized = (url || '').toLowerCase();
|
||||
if (normalized.includes('sub2api')) return true;
|
||||
@@ -196,13 +459,128 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
|
||||
// --- Models: Standard OpenAI-compatible endpoint ---
|
||||
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const directModels = await this.fetchModelsByToken(normalizedBase, apiToken);
|
||||
if (directModels.length > 0) return directModels;
|
||||
|
||||
// Session JWT cannot access /v1/models directly; discover a user key first.
|
||||
const discoveredApiToken = await this.getApiToken(normalizedBase, apiToken);
|
||||
if (!discoveredApiToken) return [];
|
||||
if (this.normalizeTokenKeyForCompare(discoveredApiToken) === this.normalizeTokenKeyForCompare(apiToken)) {
|
||||
return [];
|
||||
}
|
||||
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
|
||||
}
|
||||
|
||||
override async getApiTokens(baseUrl: string, accessToken: string): Promise<ApiTokenInfo[]> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken);
|
||||
return keys.map((item) => {
|
||||
const tokenInfo: ApiTokenInfo = {
|
||||
name: item.name,
|
||||
key: item.key,
|
||||
enabled: item.enabled,
|
||||
};
|
||||
if (item.tokenGroup) tokenInfo.tokenGroup = item.tokenGroup;
|
||||
return tokenInfo;
|
||||
});
|
||||
return (res?.data || []).map((m: any) => m.id).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
override async getApiToken(baseUrl: string, accessToken: string): Promise<string | null> {
|
||||
const tokens = await this.getApiTokens(baseUrl, accessToken);
|
||||
return tokens.find((token) => token.enabled !== false)?.key || tokens[0]?.key || null;
|
||||
}
|
||||
|
||||
override async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const directGroups = await this.listGroups(normalizedBase, accessToken);
|
||||
if (directGroups.length > 0) return directGroups;
|
||||
|
||||
const inferredFromKeys = await this.inferGroupsFromKeys(normalizedBase, accessToken);
|
||||
if (inferredFromKeys.length > 0) return inferredFromKeys;
|
||||
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
override async createApiToken(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
_platformUserId?: number,
|
||||
options?: CreateApiTokenOptions,
|
||||
): Promise<boolean> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const payload: Record<string, unknown> = {
|
||||
name: (options?.name || '').trim() || 'metapi',
|
||||
};
|
||||
|
||||
const groupId = Number.parseInt((options?.group || '').trim(), 10);
|
||||
if (Number.isFinite(groupId) && groupId > 0) {
|
||||
payload.group_id = groupId;
|
||||
}
|
||||
|
||||
const expiresInDays = this.resolveExpiresInDays(options?.expiredTime);
|
||||
if (expiresInDays) {
|
||||
payload.expires_in_days = expiresInDays;
|
||||
}
|
||||
|
||||
if (options?.unlimitedQuota === false && Number.isFinite(options.remainQuota)) {
|
||||
payload.quota = Math.max(0, Number(options.remainQuota));
|
||||
}
|
||||
|
||||
const endpoints = ['/api/v1/keys', '/api/v1/api-keys'];
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override async deleteApiToken(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
tokenKey: string,
|
||||
): Promise<boolean> {
|
||||
const targetKey = this.normalizeTokenKeyForCompare(tokenKey);
|
||||
if (!targetKey) return false;
|
||||
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
let tokenId: number | null = null;
|
||||
try {
|
||||
const items = await this.listApiKeys(normalizedBase, accessToken);
|
||||
tokenId = items.find((item) => this.normalizeTokenKeyForCompare(item.key) === targetKey)?.id || null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Upstream key already absent means local deletion is safe.
|
||||
if (!tokenId) return true;
|
||||
|
||||
const endpoints = [
|
||||
`/api/v1/keys/${tokenId}`,
|
||||
`/api/v1/api-keys/${tokenId}`,
|
||||
];
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user