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