1
0
forked from boos/BoosAPI

feat(tests): add comprehensive tests for TokenRouter and related components

- Implement tests for TokenRouter downstream policy, covering allowedRouteIds and site weight multipliers.
- Add tests for TokenRouter patterns and model mapping, including regex pattern matching and model mapping precedence.
- Create tests for BrandIcon component to verify brand detection logic.
- Develop tests for Accounts page, focusing on rebind panel behavior and refresh action on error.
- Introduce fuzzy search utility tests to validate substring and subsequence matching.
- Implement navigation focus helpers tests for building and parsing focus paths.
- Add progressive render tests to ensure correct initial and next visible counts.
- Create route model candidates index tests to validate route candidate indexing and matching logic.
This commit is contained in:
cita
2026-03-02 13:33:35 +08:00
parent 99c9e145c3
commit f6389a999a
98 changed files with 11286 additions and 801 deletions
+3 -1
View File
@@ -1,4 +1,5 @@
import type { RequestInit as UndiciRequestInit } from 'undici';
import { withSiteProxyRequestInit } from '../siteProxy.js';
export interface CheckinResult {
success: boolean;
@@ -183,7 +184,8 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
...options?.headers,
},
};
const res = await fetch(url, requestOptions);
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
const res = await fetch(url, proxiedRequestOptions);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
+55
View File
@@ -0,0 +1,55 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function resolveClaudeModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export class ClaudeAdapter extends BasePlatformAdapter {
readonly platformName = 'claude';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Official Anthropic API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(resolveClaudeModelsUrl(baseUrl), {
headers: {
'x-api-key': apiToken,
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
},
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function stripModelPrefix(name: string): string {
const trimmed = name.trim();
if (!trimmed) return '';
return trimmed.replace(/^models\//i, '');
}
function normalizeModelList(models: string[]): string[] {
const deduped = new Set<string>();
for (const model of models) {
const normalized = stripModelPrefix(model);
if (normalized) deduped.add(normalized);
}
return Array.from(deduped);
}
function isOpenAiCompatGeminiBase(baseUrl: string): boolean {
return /\/openai(?:\/|$)/i.test(baseUrl);
}
function resolveGeminiOpenAiModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/models$/i.test(normalized)) return normalized;
return `${normalized}/models`;
}
function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string {
const normalized = normalizeBaseUrl(baseUrl);
const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized)
? normalized
: `${normalized}/v1beta`;
const listBase = /\/models$/i.test(withVersion)
? withVersion
: `${withVersion}/models`;
const separator = listBase.includes('?') ? '&' : '?';
return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`;
}
export class GeminiAdapter extends BasePlatformAdapter {
readonly platformName = 'gemini';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return (
normalized.includes('generativelanguage.googleapis.com')
|| normalized.includes('googleapis.com/v1beta/openai')
|| normalized.includes('gemini.google.com')
);
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Gemini API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
const normalizedBase = normalizeBaseUrl(baseUrl);
if (isOpenAiCompatGeminiBase(normalizedBase)) {
try {
const res = await this.fetchJson<any>(resolveGeminiOpenAiModelsUrl(normalizedBase), {
headers: { Authorization: `Bearer ${apiToken}` },
});
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
} catch {}
}
try {
const res = await this.fetchJson<any>(resolveGeminiNativeModelsUrl(normalizedBase, apiToken));
const nativeModels = (res?.models || [])
.map((m: any) => String(m?.name || '').trim())
.filter(Boolean);
if (nativeModels.length > 0) return normalizeModelList(nativeModels);
} catch {}
if (!isOpenAiCompatGeminiBase(normalizedBase)) {
try {
const res = await this.fetchJson<any>(`${normalizedBase}/v1beta/openai/models`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
} catch {}
}
return [];
}
}
@@ -16,6 +16,12 @@ describe('getAdapter platform aliases', () => {
expect(getAdapter('unknown-platform')).toBeUndefined();
});
it('supports canonical openai/claude/gemini adapters', () => {
expect(getAdapter('openai')?.platformName).toBe('openai');
expect(getAdapter('claude')?.platformName).toBe('claude');
expect(getAdapter('gemini')?.platformName).toBe('gemini');
});
it('detects anyrouter URL before generic new-api adapter', async () => {
const adapter = await detectPlatform('https://anyrouter.top');
expect(adapter?.platformName).toBe('anyrouter');
@@ -25,4 +31,14 @@ describe('getAdapter platform aliases', () => {
const adapter = await detectPlatform('https://demo.donehub.example');
expect(adapter?.platformName).toBe('done-hub');
});
it('detects official openai/claude/gemini upstream URLs', async () => {
const openai = await detectPlatform('https://api.openai.com');
const claude = await detectPlatform('https://api.anthropic.com');
const gemini = await detectPlatform('https://generativelanguage.googleapis.com');
expect(openai?.platformName).toBe('openai');
expect(claude?.platformName).toBe('claude');
expect(gemini?.platformName).toBe('gemini');
});
});
+12
View File
@@ -6,9 +6,15 @@ import { VeloeraAdapter } from './veloera.js';
import { OneHubAdapter } from './oneHub.js';
import { DoneHubAdapter } from './doneHub.js';
import { Sub2ApiAdapter } from './sub2api.js';
import { OpenAiAdapter } from './openai.js';
import { ClaudeAdapter } from './claude.js';
import { GeminiAdapter } from './gemini.js';
const adapters: PlatformAdapter[] = [
// Specific forks before generic adapters for better auto-detection.
new OpenAiAdapter(),
new ClaudeAdapter(),
new GeminiAdapter(),
new AnyRouterAdapter(),
new DoneHubAdapter(),
new OneHubAdapter(),
@@ -38,6 +44,12 @@ const platformAliases: Record<string, string> = {
'one-hub': 'one-hub',
'done-hub': 'done-hub',
sub2api: 'sub2api',
// Official upstream APIs
openai: 'openai',
anthropic: 'claude',
claude: 'claude',
gemini: 'gemini',
google: 'gemini',
};
function normalizePlatform(platform: string): string {
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { OpenAiAdapter } from './openai.js';
import { ClaudeAdapter } from './claude.js';
import { GeminiAdapter } from './gemini.js';
interface RequestSnapshot {
method: string;
url: string;
headers: IncomingMessage['headers'];
}
describe('official llm upstream adapters', () => {
let server: ReturnType<typeof createServer>;
let baseUrl: string;
let requests: RequestSnapshot[] = [];
beforeEach(async () => {
requests = [];
server = createServer((req: IncomingMessage, res: ServerResponse) => {
requests.push({
method: req.method || 'GET',
url: req.url || '/',
headers: req.headers,
});
if (req.url === '/openai/v1/models') {
if (req.headers.authorization !== 'Bearer sk-openai') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: 'gpt-4.1' }, { id: 'gpt-4o-mini' }],
}));
return;
}
if (req.url === '/claude/v1/models') {
if (req.headers['x-api-key'] !== 'sk-claude' || req.headers['anthropic-version'] !== '2023-06-01') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: [{ id: 'claude-sonnet-4-5-20250929' }, { id: 'claude-haiku-4-5-20251001' }],
}));
return;
}
if (req.url?.startsWith('/gemini/v1beta/models')) {
const url = new URL(req.url, 'http://127.0.0.1');
if (url.searchParams.get('key') !== 'gemini-key') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
models: [
{ name: 'models/gemini-2.5-flash' },
{ name: 'models/gemini-2.5-pro' },
],
}));
return;
}
if (req.url === '/gemini-openai/v1beta/openai/models') {
if (req.headers.authorization !== 'Bearer gemini-key') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: 'gemini-2.5-flash' }],
}));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const addr = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => (err ? reject(err) : resolve()));
});
});
it('fetches models from openai upstream', async () => {
const adapter = new OpenAiAdapter();
const models = await adapter.getModels(`${baseUrl}/openai`, 'sk-openai');
expect(models).toEqual(['gpt-4.1', 'gpt-4o-mini']);
});
it('fetches models from claude upstream with anthropic headers', async () => {
const adapter = new ClaudeAdapter();
const models = await adapter.getModels(`${baseUrl}/claude`, 'sk-claude');
expect(models).toEqual(['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001']);
});
it('fetches models from gemini native endpoint and normalizes model names', async () => {
const adapter = new GeminiAdapter();
const models = await adapter.getModels(`${baseUrl}/gemini/v1beta`, 'gemini-key');
expect(models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']);
});
it('fetches models from gemini openai-compatible endpoint when configured', async () => {
const adapter = new GeminiAdapter();
const models = await adapter.getModels(`${baseUrl}/gemini-openai/v1beta/openai`, 'gemini-key');
expect(models).toEqual(['gemini-2.5-flash']);
});
});
+1 -1
View File
@@ -322,7 +322,7 @@ describe('NewApiAdapter', () => {
expect(token).toBe('api-key-from-token-list');
});
it('detects cookie session values as session tokens for anyrouter-like deployments', async () => {
it('detects cookie session values as session cookies for anyrouter-like deployments', async () => {
const adapter = new NewApiAdapter();
const result = await adapter.verifyToken(baseUrl, COOKIE_SESSION_TOKEN);
+3 -1
View File
@@ -1,6 +1,7 @@
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js';
import type { RequestInit as UndiciRequestInit } from 'undici';
import { createContext, runInContext } from 'node:vm';
import { withSiteProxyRequestInit } from '../siteProxy.js';
export class NewApiAdapter extends BasePlatformAdapter {
readonly platformName: string = 'new-api';
@@ -453,7 +454,8 @@ export class NewApiAdapter extends BasePlatformAdapter {
body: options?.body ?? undefined,
headers,
};
const res = await fetch(url, requestOptions);
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
const res = await fetch(url, proxiedRequestOptions);
const text = await res.text();
const parsed = this.parseJsonSafe<T>(text);
if (parsed) return parsed;
+50
View File
@@ -0,0 +1,50 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function resolveOpenAiModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export class OpenAiAdapter extends BasePlatformAdapter {
readonly platformName = 'openai';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return normalized.includes('api.openai.com');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Official OpenAI API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(resolveOpenAiModelsUrl(baseUrl), {
headers: { Authorization: `Bearer ${apiToken}` },
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
}
}