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
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { parseProxyLogPathMeta } from './proxyLogPathMeta.js';
describe('parseProxyLogPathMeta', () => {
it('parses downstream and upstream paths from prefixed message', () => {
const parsed = parseProxyLogPathMeta('[downstream:/v1/responses] [upstream:/v1/chat/completions] {"error":"x"}');
expect(parsed.downstreamPath).toBe('/v1/responses');
expect(parsed.upstreamPath).toBe('/v1/chat/completions');
expect(parsed.errorMessage).toBe('{"error":"x"}');
});
it('supports historical upstream-only logs', () => {
const parsed = parseProxyLogPathMeta('[upstream:/v1/messages] messages is required');
expect(parsed.downstreamPath).toBe(null);
expect(parsed.upstreamPath).toBe('/v1/messages');
expect(parsed.errorMessage).toBe('messages is required');
});
it('keeps plain message when no metadata exists', () => {
const parsed = parseProxyLogPathMeta('network timeout');
expect(parsed.downstreamPath).toBe(null);
expect(parsed.upstreamPath).toBe(null);
expect(parsed.errorMessage).toBe('network timeout');
});
});
+18
View File
@@ -0,0 +1,18 @@
type ProxyLogPathMeta = {
downstreamPath: string | null;
upstreamPath: string | null;
errorMessage: string;
};
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
const raw = typeof message === 'string' ? message.trim() : '';
const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i);
const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i);
const stripped = raw.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
return {
downstreamPath: downstreamMatch?.[1]?.trim() || null,
upstreamPath: upstreamMatch?.[1]?.trim() || null,
errorMessage: stripped,
};
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
buildRouteMissingTokenIndex,
normalizeMissingTokenModels,
type MissingTokenModelsByName,
} from './routeMissingTokenHints.js';
function matchesModelPattern(model: string, pattern: string): boolean {
if (!pattern) return false;
if (pattern === '*') return true;
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
return model === pattern;
}
describe('buildRouteMissingTokenIndex', () => {
it('matches missing-token models by route pattern and deduplicates accounts', () => {
const routes = [
{ id: 1, modelPattern: 'claude-*' },
{ id: 2, modelPattern: 'gpt-4o-mini' },
{ id: 3, modelPattern: '' },
];
const missingByModel: MissingTokenModelsByName = {
'claude-opus-4-6': [
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
],
'claude-code-4-6': [
{ accountId: 22, username: 'bob', siteId: 2, siteName: 'site-b' },
],
'gpt-4o-mini': [
{ accountId: 33, username: 'charlie', siteId: 3, siteName: 'site-c' },
],
};
const index = buildRouteMissingTokenIndex(routes, missingByModel, matchesModelPattern);
expect(index[1].map((item) => item.modelName)).toEqual(['claude-code-4-6', 'claude-opus-4-6']);
expect(index[1][1].accounts).toEqual([{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' }]);
expect(index[2].map((item) => item.modelName)).toEqual(['gpt-4o-mini']);
expect(index[3]).toEqual([]);
});
it('returns empty entries when missing model map is empty', () => {
const index = buildRouteMissingTokenIndex([{ id: 1, modelPattern: 'claude-*' }], {}, matchesModelPattern);
expect(index[1]).toEqual([]);
});
it('normalizes missing-token map by trimming model name and deduplicating account', () => {
const merged = normalizeMissingTokenModels({
' claude-opus-4-6 ': [
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
],
});
expect(merged['claude-opus-4-6']).toEqual([
{
accountId: 1,
username: 'alice',
siteId: 11,
siteName: 'site-a',
},
]);
});
});
@@ -0,0 +1,84 @@
export type MissingTokenModelAccount = {
accountId: number;
username: string | null;
siteId: number;
siteName: string;
};
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
export type RouteMissingTokenHint = {
modelName: string;
accounts: MissingTokenModelAccount[];
};
type RoutePatternLike = {
id: number;
modelPattern: string;
};
export function normalizeMissingTokenModels(
withoutTokenByModel: MissingTokenModelsByName,
): MissingTokenModelsByName {
const normalized: MissingTokenModelsByName = {};
for (const modelName of Object.keys(withoutTokenByModel || {})) {
const normalizedModelName = String(modelName || '').trim();
if (!normalizedModelName) continue;
const accountMap = new Map<number, MissingTokenModelAccount>();
for (const account of withoutTokenByModel[modelName] || []) {
if (!account || !Number.isFinite(account.accountId)) continue;
const accountName = (account.username || '').trim();
const siteName = String(account.siteName || '').trim();
accountMap.set(account.accountId, {
accountId: account.accountId,
username: accountName || null,
siteId: account.siteId,
siteName,
});
}
if (accountMap.size > 0) {
normalized[normalizedModelName] = Array.from(accountMap.values()).sort((a, b) => a.accountId - b.accountId);
}
}
return normalized;
}
export function buildRouteMissingTokenIndex(
routes: RoutePatternLike[],
missingByModel: MissingTokenModelsByName,
matchesModelPattern: (model: string, pattern: string) => boolean,
): Record<number, RouteMissingTokenHint[]> {
const index: Record<number, RouteMissingTokenHint[]> = {};
for (const route of routes || []) {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern) {
index[route.id] = [];
continue;
}
const matchedHints: RouteMissingTokenHint[] = [];
for (const [modelName, accounts] of Object.entries(missingByModel || {})) {
if (!matchesModelPattern(modelName, modelPattern)) continue;
const dedupedAccounts = new Map<number, MissingTokenModelAccount>();
for (const account of accounts || []) {
if (!account || !Number.isFinite(account.accountId)) continue;
dedupedAccounts.set(account.accountId, account);
}
matchedHints.push({
modelName,
accounts: Array.from(dedupedAccounts.values()).sort((a, b) => {
const siteCompare = String(a.siteName || '').localeCompare(String(b.siteName || ''), undefined, { sensitivity: 'base' });
if (siteCompare !== 0) return siteCompare;
return a.accountId - b.accountId;
}),
});
}
matchedHints.sort((a, b) => a.modelName.localeCompare(b.modelName, undefined, { sensitivity: 'base' }));
index[route.id] = matchedHints;
}
return index;
}
+24 -2
View File
@@ -8,9 +8,11 @@ describe('buildSiteSaveAction', () => {
{
name: 'site-a',
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
apiKey: 'sk-1',
proxyUrl: 'http://127.0.0.1:7890',
globalWeight: '1.2',
},
);
@@ -19,9 +21,11 @@ describe('buildSiteSaveAction', () => {
payload: {
name: 'site-a',
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
apiKey: 'sk-1',
proxyUrl: 'http://127.0.0.1:7890',
globalWeight: '1.2',
},
});
});
@@ -29,7 +33,15 @@ describe('buildSiteSaveAction', () => {
it('returns update action in edit mode with site id', () => {
const action = buildSiteSaveAction(
{ mode: 'edit', editingSiteId: 12 },
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '', proxyUrl: '' },
{
name: 'site-b',
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
apiKey: '',
proxyUrl: '',
globalWeight: '0.8',
},
);
expect(action).toEqual({
@@ -38,9 +50,11 @@ describe('buildSiteSaveAction', () => {
payload: {
name: 'site-b',
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
apiKey: '',
proxyUrl: '',
globalWeight: '0.8',
},
});
});
@@ -49,7 +63,15 @@ describe('buildSiteSaveAction', () => {
expect(() =>
buildSiteSaveAction(
{ mode: 'edit' },
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '', proxyUrl: '' },
{
name: 'site-c',
url: 'https://c.example.com',
externalCheckinUrl: '',
platform: '',
apiKey: '',
proxyUrl: '',
globalWeight: '1',
},
),
).toThrow('editingSiteId is required in edit mode');
});
+12 -2
View File
@@ -1,9 +1,11 @@
export type SiteForm = {
name: string;
url: string;
externalCheckinUrl: string;
platform: string;
apiKey: string;
proxyUrl: string;
globalWeight: string;
};
export type SiteEditorState =
@@ -15,16 +17,24 @@ type SiteSaveAction =
| { kind: 'update'; id: number; payload: SiteForm };
export function emptySiteForm(): SiteForm {
return { name: '', url: '', platform: '', apiKey: '', proxyUrl: '' };
return { name: '', url: '', externalCheckinUrl: '', platform: '', apiKey: '', proxyUrl: '', globalWeight: '1' };
}
export function siteFormFromSite(site: Partial<SiteForm> & { proxyUrl?: string | null }): SiteForm {
export function siteFormFromSite(site: Partial<SiteForm> & {
proxyUrl?: string | null;
externalCheckinUrl?: string | null;
globalWeight?: number | string | null;
}): SiteForm {
const globalWeightRaw = Number(site.globalWeight);
const globalWeight = Number.isFinite(globalWeightRaw) && globalWeightRaw > 0 ? String(globalWeightRaw) : '1';
return {
name: site.name ?? '',
url: site.url ?? '',
externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '',
apiKey: site.apiKey ?? '',
proxyUrl: site.proxyUrl ?? '',
globalWeight,
};
}