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:
cita
2026-03-04 13:46:31 +08:00
parent d49fcefa05
commit e281e45e84
71 changed files with 5435 additions and 636 deletions
+2 -1
View File
@@ -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(
+9 -2
View File
@@ -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;
}
+7 -2
View File
@@ -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 {
+187 -5
View File
@@ -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);
});
});
+383 -5
View File
@@ -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;
}
}