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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user