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,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isCloudflareChallenge, isTokenExpiredError } from './alertRules.js';
|
||||
import { appendSessionTokenRebindHint, isCloudflareChallenge, isTokenExpiredError } from './alertRules.js';
|
||||
|
||||
describe('alertRules', () => {
|
||||
it('detects cloudflare challenge messages', () => {
|
||||
@@ -18,4 +18,15 @@ describe('alertRules', () => {
|
||||
expect(isTokenExpiredError({ message: '无权进行此操作,未登录且未提供 access token' })).toBe(false);
|
||||
expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false);
|
||||
});
|
||||
|
||||
it('appends rebind hint for invalid access token messages', () => {
|
||||
expect(appendSessionTokenRebindHint('无权进行此操作,access token 无效'))
|
||||
.toContain('请在中转站重新生成系统访问令牌后重新绑定账号');
|
||||
expect(appendSessionTokenRebindHint('invalid access token'))
|
||||
.toContain('请在中转站重新生成系统访问令牌后重新绑定账号');
|
||||
});
|
||||
|
||||
it('does not append rebind hint for unrelated messages', () => {
|
||||
expect(appendSessionTokenRebindHint('network timeout')).toBe('network timeout');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ export function isCloudflareChallenge(message?: string | null): boolean {
|
||||
return text.includes('cloudflare') || text.includes('cf challenge') || text.includes('challenge required');
|
||||
}
|
||||
|
||||
const SESSION_TOKEN_REBIND_HINT = '请在中转站重新生成系统访问令牌后重新绑定账号';
|
||||
|
||||
export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean {
|
||||
if (input.status === 401 || input.status === 403) return true;
|
||||
const text = (input.message || '').toLowerCase();
|
||||
@@ -27,3 +29,20 @@ export function isTokenExpiredError(input: { status?: number; message?: string |
|
||||
text.includes('forbidden')
|
||||
);
|
||||
}
|
||||
|
||||
export function appendSessionTokenRebindHint(message?: string | null): string {
|
||||
const raw = String(message || '').trim();
|
||||
if (!raw) return raw;
|
||||
if (raw.includes(SESSION_TOKEN_REBIND_HINT)) return raw;
|
||||
|
||||
const text = raw.toLowerCase();
|
||||
const looksLikeInvalidAccessToken = (
|
||||
raw.includes('无权进行此操作,access token 无效') ||
|
||||
/invalid\s+access\s+token/.test(text) ||
|
||||
/access\s+token\s+is\s+invalid/.test(text) ||
|
||||
/access\s+token.*无效/.test(raw)
|
||||
);
|
||||
if (!looksLikeInvalidAccessToken) return raw;
|
||||
|
||||
return `${raw},${SESSION_TOKEN_REBIND_HINT}`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db, schema } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { appendSessionTokenRebindHint } from './alertRules.js';
|
||||
|
||||
export async function reportTokenExpired(params: {
|
||||
accountId: number;
|
||||
@@ -11,7 +12,8 @@ export async function reportTokenExpired(params: {
|
||||
}) {
|
||||
const accountLabel = params.username || `ID:${params.accountId}`;
|
||||
const siteLabel = params.siteName || 'unknown-site';
|
||||
const detail = params.detail ? ` (${params.detail})` : '';
|
||||
const detailText = params.detail ? appendSessionTokenRebindHint(params.detail) : '';
|
||||
const detail = detailText ? ` (${detailText})` : '';
|
||||
|
||||
db.insert(schema.events).values({
|
||||
type: 'token',
|
||||
@@ -29,7 +31,7 @@ export async function reportTokenExpired(params: {
|
||||
|
||||
setAccountRuntimeHealth(params.accountId, {
|
||||
state: 'unhealthy',
|
||||
reason: params.detail ? `访问令牌失效:${params.detail}` : '访问令牌失效',
|
||||
reason: detailText ? `访问令牌失效:${detailText}` : '访问令牌失效',
|
||||
source: 'auth',
|
||||
});
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
platform,
|
||||
proxyUrl: null,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: sites.length,
|
||||
|
||||
@@ -2,13 +2,14 @@ import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { isTokenExpiredError } from './alertRules.js';
|
||||
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
|
||||
import { reportTokenExpired } from './alertService.js';
|
||||
import { getAutoReloginConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { decryptAccountPassword } from './accountCredentialService.js';
|
||||
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
|
||||
import type { BalanceInfo } from './platforms/base.js';
|
||||
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
@@ -128,6 +129,7 @@ async function fetchTodayIncomeFromLogs(params: {
|
||||
accessToken: string;
|
||||
platform?: string | null;
|
||||
platformUserId?: number;
|
||||
proxyUrl?: string | null;
|
||||
}): Promise<number | null> {
|
||||
const baseUrl = params.baseUrl.trim();
|
||||
const accessToken = params.accessToken.trim();
|
||||
@@ -162,10 +164,10 @@ async function fetchTodayIncomeFromLogs(params: {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/log/self?${query.toString()}`, {
|
||||
const response = await fetch(`${baseUrl}/api/log/self?${query.toString()}`, withExplicitProxyRequestInit(params.proxyUrl, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
}));
|
||||
if (!response.ok) break;
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
@@ -258,7 +260,7 @@ export async function refreshBalance(accountId: number) {
|
||||
|
||||
const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId);
|
||||
const handleBalanceError = async (err: any) => {
|
||||
const message = err?.message || 'unknown error';
|
||||
const message = appendSessionTokenRebindHint(err?.message || 'unknown error');
|
||||
setAccountRuntimeHealth(account.id, {
|
||||
state: 'unhealthy',
|
||||
reason: message,
|
||||
@@ -272,7 +274,7 @@ export async function refreshBalance(accountId: number) {
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -310,6 +312,7 @@ export async function refreshBalance(accountId: number) {
|
||||
accessToken: activeAccessToken,
|
||||
platform: site.platform,
|
||||
platformUserId,
|
||||
proxyUrl: site.proxyUrl,
|
||||
});
|
||||
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
|
||||
balanceInfo.todayIncome = fallbackIncome;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type ServiceModule = typeof import('./downstreamApiKeyService.js');
|
||||
type ConfigModule = typeof import('../config.js');
|
||||
|
||||
describe('downstreamApiKeyService', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let service: ServiceModule;
|
||||
let config: ConfigModule['config'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-key-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const configModule = await import('../config.js');
|
||||
const serviceModule = await import('./downstreamApiKeyService.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
config = configModule.config;
|
||||
service = serviceModule;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.downstreamApiKeys).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
config.proxyToken = 'sk-global-proxy-token';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('authorizes global proxy token when no managed key matches', () => {
|
||||
const result = service.authorizeDownstreamToken('sk-global-proxy-token');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.key).toBeNull();
|
||||
expect(result.policy.allowedRouteIds).toEqual([]);
|
||||
expect(result.policy.supportedModels).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects managed keys by lifecycle guards (disabled, expired, over budget, over requests)', () => {
|
||||
const now = Date.now();
|
||||
|
||||
const disabled = db.insert(schema.downstreamApiKeys).values({
|
||||
name: 'disabled',
|
||||
key: 'sk-disabled',
|
||||
enabled: false,
|
||||
}).returning().get();
|
||||
|
||||
const expired = db.insert(schema.downstreamApiKeys).values({
|
||||
name: 'expired',
|
||||
key: 'sk-expired',
|
||||
enabled: true,
|
||||
expiresAt: new Date(now - 60_000).toISOString(),
|
||||
}).returning().get();
|
||||
|
||||
const overBudget = db.insert(schema.downstreamApiKeys).values({
|
||||
name: 'over-budget',
|
||||
key: 'sk-over-budget',
|
||||
enabled: true,
|
||||
maxCost: 1,
|
||||
usedCost: 1.2,
|
||||
}).returning().get();
|
||||
|
||||
const overRequests = db.insert(schema.downstreamApiKeys).values({
|
||||
name: 'over-requests',
|
||||
key: 'sk-over-requests',
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
usedRequests: 10,
|
||||
}).returning().get();
|
||||
|
||||
const r1 = service.authorizeDownstreamToken(disabled.key);
|
||||
const r2 = service.authorizeDownstreamToken(expired.key);
|
||||
const r3 = service.authorizeDownstreamToken(overBudget.key);
|
||||
const r4 = service.authorizeDownstreamToken(overRequests.key);
|
||||
|
||||
expect(r1.ok).toBe(false);
|
||||
expect(r2.ok).toBe(false);
|
||||
expect(r3.ok).toBe(false);
|
||||
expect(r4.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('parses policy fields and supports model matching patterns', () => {
|
||||
const row = db.insert(schema.downstreamApiKeys).values({
|
||||
name: 'project-a',
|
||||
key: 'sk-project-a',
|
||||
enabled: true,
|
||||
supportedModels: JSON.stringify(['re:^claude-(opus|sonnet)-4-6$', 'gpt-4o-mini']),
|
||||
allowedRouteIds: JSON.stringify([101, 102]),
|
||||
siteWeightMultipliers: JSON.stringify({ '1': 2.5, '7': 0.4 }),
|
||||
}).returning().get();
|
||||
|
||||
const result = service.authorizeDownstreamToken(row.key);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.key?.id).toBe(row.id);
|
||||
expect(result.policy.allowedRouteIds).toEqual([101, 102]);
|
||||
expect(result.policy.siteWeightMultipliers[1]).toBeCloseTo(2.5);
|
||||
expect(result.policy.siteWeightMultipliers[7]).toBeCloseTo(0.4);
|
||||
|
||||
expect(service.isModelAllowedByPolicy('claude-opus-4-6', result.policy)).toBe(true);
|
||||
expect(service.isModelAllowedByPolicy('gpt-4o-mini', result.policy)).toBe(true);
|
||||
expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats selected groups as additional allowed model scope (union semantics)', () => {
|
||||
const claudeGroup = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const policy = {
|
||||
supportedModels: ['gpt-4o-mini'],
|
||||
allowedRouteIds: [claudeGroup.id],
|
||||
siteWeightMultipliers: {},
|
||||
};
|
||||
|
||||
expect(service.isModelAllowedByPolicy('claude-opus-4-6', policy)).toBe(false);
|
||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
|
||||
});
|
||||
|
||||
it('authorizes by selected group model pattern only, not arbitrary internal models', () => {
|
||||
const virtualModelGroup = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'claude-opus-4-6',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const policy = {
|
||||
supportedModels: [],
|
||||
allowedRouteIds: [virtualModelGroup.id],
|
||||
siteWeightMultipliers: {},
|
||||
};
|
||||
|
||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-6', policy)).toBe(false);
|
||||
});
|
||||
|
||||
it('authorizes models by selected route display name alias', () => {
|
||||
const aliasRoute = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 're:^claude-(opus|sonnet)-4-5$',
|
||||
displayName: 'claude-opus-4-6',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const policy = {
|
||||
supportedModels: [],
|
||||
allowedRouteIds: [aliasRoute.id],
|
||||
siteWeightMultipliers: {},
|
||||
};
|
||||
|
||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-5', policy)).toBe(true);
|
||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
|
||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from './downstreamPolicyTypes.js';
|
||||
|
||||
export type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect;
|
||||
|
||||
export type DownstreamApiKeyPolicyView = {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
keyMasked: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
expiresAt: string | null;
|
||||
maxCost: number | null;
|
||||
usedCost: number;
|
||||
maxRequests: number | null;
|
||||
usedRequests: number;
|
||||
supportedModels: string[];
|
||||
allowedRouteIds: number[];
|
||||
siteWeightMultipliers: Record<number, number>;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type DownstreamTokenAuthSuccess = {
|
||||
ok: true;
|
||||
source: 'managed' | 'global';
|
||||
token: string;
|
||||
key: DownstreamApiKeyPolicyView | null;
|
||||
policy: DownstreamRoutingPolicy;
|
||||
};
|
||||
|
||||
export type DownstreamTokenAuthFailure = {
|
||||
ok: false;
|
||||
statusCode: number;
|
||||
error: string;
|
||||
reason: 'missing' | 'invalid' | 'disabled' | 'expired' | 'over_cost' | 'over_requests';
|
||||
};
|
||||
|
||||
export type DownstreamTokenAuthResult = DownstreamTokenAuthSuccess | DownstreamTokenAuthFailure;
|
||||
|
||||
function isRegexModelPattern(pattern: string): boolean {
|
||||
return pattern.trim().toLowerCase().startsWith('re:');
|
||||
}
|
||||
|
||||
function parseRegexModelPattern(pattern: string): RegExp | null {
|
||||
if (!isRegexModelPattern(pattern)) return null;
|
||||
const body = pattern.trim().slice(3).trim();
|
||||
if (!body) return null;
|
||||
try {
|
||||
return new RegExp(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToken(raw: string): string {
|
||||
return (raw || '').trim();
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (!value) return '';
|
||||
if (value.length <= 8) return '****';
|
||||
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
function normalizePositiveNumberOrNull(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizePositiveIntegerOrNull(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const normalized = Math.trunc(n);
|
||||
if (normalized < 0) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseJson(value: string | null | undefined): unknown {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSupportedModelsInput(input: unknown): string[] {
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
.split(/\r?\n|,/g)
|
||||
.map((item) => item.trim())
|
||||
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeAllowedRouteIdsInput(input: unknown): number[] {
|
||||
const rawValues = Array.isArray(input)
|
||||
? input
|
||||
: (typeof input === 'string' ? input.split(/\r?\n|,/g) : []);
|
||||
|
||||
const routeIds: number[] = [];
|
||||
for (const item of rawValues) {
|
||||
const n = Number(item);
|
||||
if (!Number.isFinite(n)) continue;
|
||||
const normalized = Math.trunc(n);
|
||||
if (normalized <= 0 || routeIds.includes(normalized)) continue;
|
||||
routeIds.push(normalized);
|
||||
if (routeIds.length >= 500) break;
|
||||
}
|
||||
|
||||
return routeIds;
|
||||
}
|
||||
|
||||
export function normalizeSiteWeightMultipliersInput(input: unknown): Record<number, number> {
|
||||
const raw = (typeof input === 'string')
|
||||
? parseJson(input)
|
||||
: input;
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<number, number> = {};
|
||||
for (const [rawSiteId, rawMultiplier] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const siteId = Number(rawSiteId);
|
||||
const multiplier = Number(rawMultiplier);
|
||||
if (!Number.isFinite(siteId) || !Number.isFinite(multiplier)) continue;
|
||||
const normalizedSiteId = Math.trunc(siteId);
|
||||
if (normalizedSiteId <= 0 || multiplier <= 0) continue;
|
||||
result[normalizedSiteId] = multiplier;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function matchesDownstreamModelPattern(model: string, pattern: string): boolean {
|
||||
const normalizedPattern = (pattern || '').trim();
|
||||
if (!normalizedPattern) return false;
|
||||
|
||||
if (normalizedPattern === model) return true;
|
||||
|
||||
if (isRegexModelPattern(normalizedPattern)) {
|
||||
const re = parseRegexModelPattern(normalizedPattern);
|
||||
return !!re && re.test(model);
|
||||
}
|
||||
|
||||
return minimatch(model, normalizedPattern);
|
||||
}
|
||||
|
||||
export function isModelAllowedByPolicy(model: string, policy: DownstreamRoutingPolicy): boolean {
|
||||
const patterns = Array.isArray(policy.supportedModels)
|
||||
? policy.supportedModels
|
||||
: [];
|
||||
|
||||
if (patterns.length === 0) return true;
|
||||
|
||||
return patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern));
|
||||
}
|
||||
|
||||
function isModelMatchedByAllowedRoutes(model: string, allowedRouteIds: number[]): boolean {
|
||||
if (allowedRouteIds.length === 0) return false;
|
||||
|
||||
const routes = db.select({
|
||||
id: schema.tokenRoutes.id,
|
||||
modelPattern: schema.tokenRoutes.modelPattern,
|
||||
displayName: schema.tokenRoutes.displayName,
|
||||
})
|
||||
.from(schema.tokenRoutes)
|
||||
.where(and(
|
||||
inArray(schema.tokenRoutes.id, allowedRouteIds),
|
||||
eq(schema.tokenRoutes.enabled, true),
|
||||
))
|
||||
.all();
|
||||
|
||||
return routes.some((route) => {
|
||||
if (matchesDownstreamModelPattern(model, route.modelPattern)) return true;
|
||||
const alias = (route.displayName || '').trim();
|
||||
return !!alias && alias === model;
|
||||
});
|
||||
}
|
||||
|
||||
export function isModelAllowedByPolicyOrAllowedRoutes(model: string, policy: DownstreamRoutingPolicy): boolean {
|
||||
const patterns = normalizeSupportedModelsInput(policy.supportedModels);
|
||||
const allowedRouteIds = normalizeAllowedRouteIdsInput(policy.allowedRouteIds);
|
||||
const hasPatternRules = patterns.length > 0;
|
||||
const hasRouteRules = allowedRouteIds.length > 0;
|
||||
|
||||
if (!hasPatternRules && !hasRouteRules) return true;
|
||||
|
||||
if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasRouteRules) return false;
|
||||
|
||||
return isModelMatchedByAllowedRoutes(model, allowedRouteIds);
|
||||
}
|
||||
|
||||
export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): DownstreamApiKeyPolicyView {
|
||||
const supportedModels = normalizeSupportedModelsInput(parseJson(row.supportedModels));
|
||||
const allowedRouteIds = normalizeAllowedRouteIdsInput(parseJson(row.allowedRouteIds));
|
||||
const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(parseJson(row.siteWeightMultipliers));
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
key: row.key,
|
||||
keyMasked: maskSecret(row.key),
|
||||
description: row.description || null,
|
||||
enabled: !!row.enabled,
|
||||
expiresAt: row.expiresAt || null,
|
||||
maxCost: row.maxCost ?? null,
|
||||
usedCost: Number(row.usedCost || 0),
|
||||
maxRequests: row.maxRequests ?? null,
|
||||
usedRequests: Number(row.usedRequests || 0),
|
||||
supportedModels,
|
||||
allowedRouteIds,
|
||||
siteWeightMultipliers,
|
||||
lastUsedAt: row.lastUsedAt || null,
|
||||
createdAt: row.createdAt || null,
|
||||
updatedAt: row.updatedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPolicyFromView(view: Pick<DownstreamApiKeyPolicyView, 'supportedModels' | 'allowedRouteIds' | 'siteWeightMultipliers'>): DownstreamRoutingPolicy {
|
||||
return {
|
||||
supportedModels: normalizeSupportedModelsInput(view.supportedModels),
|
||||
allowedRouteIds: normalizeAllowedRouteIdsInput(view.allowedRouteIds),
|
||||
siteWeightMultipliers: normalizeSiteWeightMultipliersInput(view.siteWeightMultipliers),
|
||||
};
|
||||
}
|
||||
|
||||
export function listDownstreamApiKeys(): DownstreamApiKeyPolicyView[] {
|
||||
return db.select().from(schema.downstreamApiKeys)
|
||||
.all()
|
||||
.map((row) => toDownstreamApiKeyPolicyView(row))
|
||||
.sort((a, b) => b.id - a.id);
|
||||
}
|
||||
|
||||
export function getDownstreamApiKeyById(id: number): DownstreamApiKeyPolicyView | null {
|
||||
const row = db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.id, id))
|
||||
.get();
|
||||
if (!row) return null;
|
||||
return toDownstreamApiKeyPolicyView(row);
|
||||
}
|
||||
|
||||
export function getManagedDownstreamApiKeyByToken(token: string): DownstreamApiKeyPolicyView | null {
|
||||
const normalizedToken = normalizeToken(token);
|
||||
if (!normalizedToken) return null;
|
||||
|
||||
const row = db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.key, normalizedToken))
|
||||
.get();
|
||||
|
||||
if (!row) return null;
|
||||
return toDownstreamApiKeyPolicyView(row);
|
||||
}
|
||||
|
||||
export function getDefaultGlobalPolicy(): DownstreamRoutingPolicy {
|
||||
return EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
||||
}
|
||||
|
||||
export function authorizeDownstreamToken(token: string): DownstreamTokenAuthResult {
|
||||
const normalizedToken = normalizeToken(token);
|
||||
if (!normalizedToken) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 401,
|
||||
error: 'Missing Authorization or x-api-key header',
|
||||
reason: 'missing',
|
||||
};
|
||||
}
|
||||
|
||||
const managed = getManagedDownstreamApiKeyByToken(normalizedToken);
|
||||
if (managed) {
|
||||
if (!managed.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 403,
|
||||
error: 'API key is disabled',
|
||||
reason: 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (managed.expiresAt) {
|
||||
const expiresAtTs = Date.parse(managed.expiresAt);
|
||||
if (Number.isFinite(expiresAtTs) && expiresAtTs <= Date.now()) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 403,
|
||||
error: 'API key is expired',
|
||||
reason: 'expired',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (managed.maxCost !== null && managed.usedCost >= managed.maxCost) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 403,
|
||||
error: 'API key has exceeded max cost',
|
||||
reason: 'over_cost',
|
||||
};
|
||||
}
|
||||
|
||||
if (managed.maxRequests !== null && managed.usedRequests >= managed.maxRequests) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 403,
|
||||
error: 'API key has exceeded max requests',
|
||||
reason: 'over_requests',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
source: 'managed',
|
||||
token: normalizedToken,
|
||||
key: managed,
|
||||
policy: toPolicyFromView(managed),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedToken === config.proxyToken) {
|
||||
return {
|
||||
ok: true,
|
||||
source: 'global',
|
||||
token: normalizedToken,
|
||||
key: null,
|
||||
policy: getDefaultGlobalPolicy(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 403,
|
||||
error: 'Invalid API key',
|
||||
reason: 'invalid',
|
||||
};
|
||||
}
|
||||
|
||||
export function consumeManagedKeyRequest(keyId: number): void {
|
||||
const row = db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.id, keyId))
|
||||
.get();
|
||||
if (!row) return;
|
||||
|
||||
db.update(schema.downstreamApiKeys).set({
|
||||
usedRequests: Math.max(0, (row.usedRequests || 0) + 1),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
|
||||
}
|
||||
|
||||
export function recordManagedKeyCostUsage(keyId: number, estimatedCost: number): void {
|
||||
const cost = Number(estimatedCost);
|
||||
if (!Number.isFinite(cost) || cost <= 0) return;
|
||||
|
||||
const row = db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.id, keyId))
|
||||
.get();
|
||||
if (!row) return;
|
||||
|
||||
db.update(schema.downstreamApiKeys).set({
|
||||
usedCost: Math.max(0, (row.usedCost || 0) + cost),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
|
||||
}
|
||||
|
||||
export function normalizeDownstreamApiKeyPayload(input: {
|
||||
name?: unknown;
|
||||
key?: unknown;
|
||||
description?: unknown;
|
||||
enabled?: unknown;
|
||||
expiresAt?: unknown;
|
||||
maxCost?: unknown;
|
||||
maxRequests?: unknown;
|
||||
supportedModels?: unknown;
|
||||
allowedRouteIds?: unknown;
|
||||
siteWeightMultipliers?: unknown;
|
||||
}) {
|
||||
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
||||
const key = typeof input.key === 'string' ? input.key.trim() : '';
|
||||
const description = typeof input.description === 'string'
|
||||
? input.description.trim()
|
||||
: '';
|
||||
const enabled = input.enabled === undefined ? true : !!input.enabled;
|
||||
|
||||
const expiresAtRaw = typeof input.expiresAt === 'string'
|
||||
? input.expiresAt.trim()
|
||||
: (input.expiresAt === null ? '' : '');
|
||||
let expiresAt: string | null = null;
|
||||
if (expiresAtRaw) {
|
||||
const ts = Date.parse(expiresAtRaw);
|
||||
if (!Number.isFinite(ts)) {
|
||||
throw new Error('expiresAt 必须是有效时间');
|
||||
}
|
||||
expiresAt = new Date(ts).toISOString();
|
||||
}
|
||||
|
||||
const maxCost = normalizePositiveNumberOrNull(input.maxCost);
|
||||
const maxRequests = normalizePositiveIntegerOrNull(input.maxRequests);
|
||||
const supportedModels = normalizeSupportedModelsInput(input.supportedModels);
|
||||
const allowedRouteIds = normalizeAllowedRouteIdsInput(input.allowedRouteIds);
|
||||
const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(input.siteWeightMultipliers);
|
||||
|
||||
return {
|
||||
name,
|
||||
key,
|
||||
description: description || null,
|
||||
enabled,
|
||||
expiresAt,
|
||||
maxCost,
|
||||
maxRequests,
|
||||
supportedModels,
|
||||
allowedRouteIds,
|
||||
siteWeightMultipliers,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPersistenceJson(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (Array.isArray(value) && value.length === 0) return null;
|
||||
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface DownstreamRoutingPolicy {
|
||||
supportedModels: string[];
|
||||
allowedRouteIds: number[];
|
||||
siteWeightMultipliers: Record<number, number>;
|
||||
}
|
||||
|
||||
export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
|
||||
supportedModels: [],
|
||||
allowedRouteIds: [],
|
||||
siteWeightMultipliers: {},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { withSiteProxyRequestInit } from './siteProxy.js';
|
||||
|
||||
const PRICE_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const PRICE_CACHE_FAILURE_TTL_MS = 60 * 1000;
|
||||
@@ -258,13 +259,15 @@ async function fetchJson(url: string, options?: UndiciRequestInit): Promise<unkn
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
body: options?.body ?? undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
...withSiteProxyRequestInit(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
body: options?.body ?? undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -17,7 +17,10 @@ function normalizeModels(models: string[]): string[] {
|
||||
}
|
||||
|
||||
function isExactModelPattern(modelPattern: string): boolean {
|
||||
return !/[\*\?\[]/.test(modelPattern);
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||||
return !/[\*\?\[]/.test(normalized);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
||||
|
||||
@@ -77,6 +77,34 @@ describe('notifyService', () => {
|
||||
).rejects.toThrow(/smtp auth failed|通知发送失败/);
|
||||
});
|
||||
|
||||
it('includes failed channel details when all enabled channels fail', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
config.webhookEnabled = true;
|
||||
config.webhookUrl = 'https://webhook.example.com/notify';
|
||||
config.barkEnabled = true;
|
||||
config.barkUrl = 'https://api.day.app/mock-key';
|
||||
config.smtpEnabled = false;
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
});
|
||||
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
|
||||
await expect(
|
||||
(sendNotification as any)('测试通知', 'message', 'info', {
|
||||
bypassThrottle: true,
|
||||
throwOnFailure: true,
|
||||
}),
|
||||
).rejects.toThrow(/webhook|bark|Webhook 响应状态|Bark 响应状态/i);
|
||||
});
|
||||
|
||||
it('includes local time and utc time labels in smtp payload', async () => {
|
||||
sendMailMock.mockResolvedValue({ accepted: ['receiver@example.com'] });
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetch } from 'undici';
|
||||
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
||||
|
||||
const SELF_LOG_FETCH_TIMEOUT_MS = 8_000;
|
||||
const SELF_LOG_PAGE_SIZE = 20;
|
||||
@@ -23,6 +24,7 @@ interface ProxyUsageFallbackInput {
|
||||
url: string;
|
||||
platform: string;
|
||||
apiKey?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
};
|
||||
account: {
|
||||
accessToken?: string | null;
|
||||
@@ -233,11 +235,11 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
||||
headers['New-Api-User'] = String(userId);
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/api/log/self?${query}`, {
|
||||
const response = await fetch(`${baseUrl}/api/log/self?${query}`, withExplicitProxyRequestInit(input.site.proxyUrl, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
|
||||
describe('siteProxy', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-proxy-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const { invalidateSiteProxyCache } = await import('./siteProxy.js');
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
invalidateSiteProxyCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('resolves longest matched site proxy url', async () => {
|
||||
db.insert(schema.sites).values([
|
||||
{
|
||||
name: 'base-site',
|
||||
url: 'https://relay.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7891',
|
||||
},
|
||||
{
|
||||
name: 'openai-site',
|
||||
url: 'https://relay.example.com/openai',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
},
|
||||
]).run();
|
||||
|
||||
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
|
||||
expect(resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models'))
|
||||
.toBe('http://127.0.0.1:7890');
|
||||
expect(resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models'))
|
||||
.toBe('http://127.0.0.1:7891');
|
||||
});
|
||||
|
||||
it('injects dispatcher when proxy exists', async () => {
|
||||
db.insert(schema.sites).values({
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
}).run();
|
||||
|
||||
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
|
||||
const requestInit = withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect('dispatcher' in requestInit).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { db, schema } from '../db/index.js';
|
||||
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { ProxyAgent } from 'undici';
|
||||
|
||||
const SITE_PROXY_CACHE_TTL_MS = 3_000;
|
||||
const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
||||
'http:',
|
||||
'https:',
|
||||
'socks:',
|
||||
'socks4:',
|
||||
'socks4a:',
|
||||
'socks5:',
|
||||
'socks5h:',
|
||||
]);
|
||||
|
||||
type SiteProxyRow = {
|
||||
siteUrl: string;
|
||||
proxyUrl: string | null;
|
||||
};
|
||||
|
||||
type ParsedSiteProxyInput = {
|
||||
present: boolean;
|
||||
valid: boolean;
|
||||
proxyUrl: string | null;
|
||||
};
|
||||
|
||||
let siteProxyCache: {
|
||||
loadedAt: number;
|
||||
rows: SiteProxyRow[];
|
||||
} = {
|
||||
loadedAt: 0,
|
||||
rows: [],
|
||||
};
|
||||
|
||||
const dispatcherCache = new Map<string, Dispatcher>();
|
||||
|
||||
function normalizeSiteUrl(value: string): string {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const pathname = parsed.pathname.replace(/\/+$/, '');
|
||||
const normalizedPath = pathname === '/' ? '' : pathname;
|
||||
return `${parsed.origin}${normalizedPath}`;
|
||||
} catch {
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedSiteProxyRows(nowMs = Date.now()): SiteProxyRow[] {
|
||||
if ((nowMs - siteProxyCache.loadedAt) < SITE_PROXY_CACHE_TTL_MS) {
|
||||
return siteProxyCache.rows;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = db
|
||||
.select({
|
||||
siteUrl: schema.sites.url,
|
||||
proxyUrl: schema.sites.proxyUrl,
|
||||
})
|
||||
.from(schema.sites)
|
||||
.all();
|
||||
|
||||
siteProxyCache = {
|
||||
loadedAt: nowMs,
|
||||
rows: rows.map((row) => ({
|
||||
siteUrl: normalizeSiteUrl(row.siteUrl),
|
||||
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
siteProxyCache = { loadedAt: nowMs, rows: [] };
|
||||
}
|
||||
|
||||
return siteProxyCache.rows;
|
||||
}
|
||||
|
||||
function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
|
||||
const normalized = normalizeSiteProxyUrl(proxyUrl);
|
||||
if (!normalized) return undefined;
|
||||
|
||||
const cached = dispatcherCache.get(normalized);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const dispatcher = new ProxyAgent(normalized);
|
||||
dispatcherCache.set(normalized, dispatcher);
|
||||
return dispatcher;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSiteProxyUrl(input: unknown): string | null {
|
||||
if (typeof input !== 'string') return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (!SUPPORTED_PROXY_PROTOCOLS.has(parsed.protocol.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSiteProxyUrlInput(input: unknown): ParsedSiteProxyInput {
|
||||
if (input === undefined) {
|
||||
return { present: false, valid: true, proxyUrl: null };
|
||||
}
|
||||
if (input === null) {
|
||||
return { present: true, valid: true, proxyUrl: null };
|
||||
}
|
||||
|
||||
if (typeof input !== 'string') {
|
||||
return { present: true, valid: false, proxyUrl: null };
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return { present: true, valid: true, proxyUrl: null };
|
||||
}
|
||||
|
||||
const normalized = normalizeSiteProxyUrl(trimmed);
|
||||
if (!normalized) {
|
||||
return { present: true, valid: false, proxyUrl: null };
|
||||
}
|
||||
|
||||
return {
|
||||
present: true,
|
||||
valid: true,
|
||||
proxyUrl: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateSiteProxyCache(): void {
|
||||
siteProxyCache = { loadedAt: 0, rows: [] };
|
||||
}
|
||||
|
||||
export function resolveSiteProxyUrlByRequestUrl(requestUrl: string): string | null {
|
||||
const normalizedRequestUrl = normalizeSiteUrl(requestUrl);
|
||||
if (!normalizedRequestUrl) return null;
|
||||
|
||||
const rows = getCachedSiteProxyRows();
|
||||
let bestMatch: string | null = null;
|
||||
let bestMatchLength = -1;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.proxyUrl) continue;
|
||||
if (!row.siteUrl) continue;
|
||||
|
||||
const isPrefixMatch = (
|
||||
normalizedRequestUrl === row.siteUrl
|
||||
|| normalizedRequestUrl.startsWith(`${row.siteUrl}/`)
|
||||
|| normalizedRequestUrl.startsWith(`${row.siteUrl}?`)
|
||||
);
|
||||
if (!isPrefixMatch) continue;
|
||||
|
||||
if (row.siteUrl.length > bestMatchLength) {
|
||||
bestMatch = row.proxyUrl;
|
||||
bestMatchLength = row.siteUrl.length;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
export function withSiteProxyRequestInit(
|
||||
requestUrl: string,
|
||||
options?: UndiciRequestInit,
|
||||
): UndiciRequestInit {
|
||||
const proxyUrl = resolveSiteProxyUrlByRequestUrl(requestUrl);
|
||||
if (!proxyUrl) return options ?? {};
|
||||
|
||||
const dispatcher = getDispatcherByProxyUrl(proxyUrl);
|
||||
if (!dispatcher) return options ?? {};
|
||||
|
||||
return {
|
||||
...(options || {}),
|
||||
dispatcher,
|
||||
};
|
||||
}
|
||||
|
||||
export function withExplicitProxyRequestInit(
|
||||
proxyUrl: string | null | undefined,
|
||||
options?: UndiciRequestInit,
|
||||
): UndiciRequestInit {
|
||||
const normalized = normalizeSiteProxyUrl(proxyUrl);
|
||||
if (!normalized) return options ?? {};
|
||||
|
||||
const dispatcher = getDispatcherByProxyUrl(normalized);
|
||||
if (!dispatcher) return options ?? {};
|
||||
|
||||
return {
|
||||
...(options || {}),
|
||||
dispatcher,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||
|
||||
describe('TokenRouter downstream policy', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-policy-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const tokenRouterModule = await import('./tokenRouter.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('respects allowedRouteIds when selecting channels', () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
name: 'site-a',
|
||||
url: 'https://a.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'user-a',
|
||||
accessToken: 'access-a',
|
||||
apiToken: 'sk-a',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const routeAllowed = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'claude-opus-4-6',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const routeBlocked = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: routeAllowed.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).run();
|
||||
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: routeBlocked.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).run();
|
||||
|
||||
const router = new TokenRouter();
|
||||
|
||||
const allowedPick = router.selectChannel('claude-opus-4-6', {
|
||||
allowedRouteIds: [routeAllowed.id],
|
||||
supportedModels: [],
|
||||
siteWeightMultipliers: {},
|
||||
});
|
||||
const blockedPick = router.selectChannel('gpt-4o-mini', {
|
||||
allowedRouteIds: [routeAllowed.id],
|
||||
supportedModels: [],
|
||||
siteWeightMultipliers: {},
|
||||
});
|
||||
|
||||
expect(allowedPick).toBeTruthy();
|
||||
expect(allowedPick?.channel.routeId).toBe(routeAllowed.id);
|
||||
expect(blockedPick).toBeNull();
|
||||
});
|
||||
|
||||
it('applies site weight multipliers to probability explanation', () => {
|
||||
const siteHigh = db.insert(schema.sites).values({
|
||||
name: 'high-site',
|
||||
url: 'https://high.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const siteLow = db.insert(schema.sites).values({
|
||||
name: 'low-site',
|
||||
url: 'https://low.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountHigh = db.insert(schema.accounts).values({
|
||||
siteId: siteHigh.id,
|
||||
username: 'user-high',
|
||||
accessToken: 'access-high',
|
||||
apiToken: 'sk-high',
|
||||
status: 'active',
|
||||
unitCost: 1,
|
||||
balance: 100,
|
||||
}).returning().get();
|
||||
|
||||
const accountLow = db.insert(schema.accounts).values({
|
||||
siteId: siteLow.id,
|
||||
username: 'user-low',
|
||||
accessToken: 'access-low',
|
||||
apiToken: 'sk-low',
|
||||
status: 'active',
|
||||
unitCost: 1,
|
||||
balance: 100,
|
||||
}).returning().get();
|
||||
|
||||
const route = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'claude-sonnet-4-6',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channelHigh = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountHigh.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channelLow = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountLow.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const decision = router.explainSelectionForRoute(
|
||||
route.id,
|
||||
'claude-sonnet-4-6',
|
||||
[],
|
||||
{
|
||||
allowedRouteIds: [route.id],
|
||||
supportedModels: [],
|
||||
siteWeightMultipliers: {
|
||||
[siteHigh.id]: 4,
|
||||
[siteLow.id]: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const highCandidate = decision.candidates.find((candidate) => candidate.channelId === channelHigh.id);
|
||||
const lowCandidate = decision.candidates.find((candidate) => candidate.channelId === channelLow.id);
|
||||
|
||||
expect(highCandidate).toBeTruthy();
|
||||
expect(lowCandidate).toBeTruthy();
|
||||
expect((highCandidate?.probability || 0)).toBeGreaterThan(lowCandidate?.probability || 0);
|
||||
});
|
||||
|
||||
it('supports union semantics between supportedModels and allowedRouteIds', () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
name: 'site-union',
|
||||
url: 'https://union.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'user-union',
|
||||
accessToken: 'access-union',
|
||||
apiToken: 'sk-union',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const claudeGroupRoute = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const gptExactRoute = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: claudeGroupRoute.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).run();
|
||||
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: gptExactRoute.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).run();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const policy = {
|
||||
allowedRouteIds: [claudeGroupRoute.id],
|
||||
supportedModels: ['gpt-4o-mini'],
|
||||
siteWeightMultipliers: {},
|
||||
};
|
||||
|
||||
const claudePick = router.selectChannel('claude-opus-4-6', policy);
|
||||
const gptPick = router.selectChannel('gpt-4o-mini', policy);
|
||||
|
||||
expect(claudePick?.channel.routeId).toBe(claudeGroupRoute.id);
|
||||
expect(gptPick?.channel.routeId).toBe(gptExactRoute.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||
|
||||
describe('TokenRouter patterns and model mapping', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let dataDir = '';
|
||||
let idSeed = 0;
|
||||
|
||||
const nextId = () => {
|
||||
idSeed += 1;
|
||||
return idSeed;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-patterns-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const tokenRouterModule = await import('./tokenRouter.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
idSeed = 0;
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
function createSite(namePrefix: string) {
|
||||
const id = nextId();
|
||||
return db.insert(schema.sites).values({
|
||||
name: `${namePrefix}-${id}`,
|
||||
url: `https://${namePrefix}-${id}.example.com`,
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
}
|
||||
|
||||
function createAccount(siteId: number, usernamePrefix: string) {
|
||||
const id = nextId();
|
||||
return db.insert(schema.accounts).values({
|
||||
siteId,
|
||||
username: `${usernamePrefix}-${id}`,
|
||||
accessToken: `access-${id}`,
|
||||
apiToken: `sk-${id}`,
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
}
|
||||
|
||||
function createRouteWithSingleChannel(
|
||||
modelPattern: string,
|
||||
modelMapping?: string,
|
||||
options?: { displayName?: string; sourceModel?: string | null },
|
||||
) {
|
||||
const site = createSite('pattern-site');
|
||||
const account = createAccount(site.id, 'pattern-user');
|
||||
const route = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern,
|
||||
displayName: options?.displayName,
|
||||
modelMapping,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const channel = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
sourceModel: options?.sourceModel ?? null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
return { route, channel };
|
||||
}
|
||||
|
||||
it('matches routes with re: regex patterns', () => {
|
||||
createRouteWithSingleChannel('re:^claude-(opus|sonnet)-4-6$');
|
||||
const router = new TokenRouter();
|
||||
|
||||
const matched = router.selectChannel('claude-opus-4-6');
|
||||
const unmatched = router.selectChannel('claude-haiku-4-6');
|
||||
|
||||
expect(matched).toBeTruthy();
|
||||
expect(matched?.actualModel).toBe('claude-opus-4-6');
|
||||
expect(unmatched).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores invalid re: patterns and falls back to next matched route', () => {
|
||||
const invalid = createRouteWithSingleChannel('re:([a-z');
|
||||
const glob = createRouteWithSingleChannel('claude-*');
|
||||
const router = new TokenRouter();
|
||||
|
||||
const selected = router.selectChannel('claude-opus-4-6');
|
||||
expect(selected).toBeTruthy();
|
||||
expect(selected?.channel.id).toBe(glob.channel.id);
|
||||
expect(selected?.channel.id).not.toBe(invalid.channel.id);
|
||||
});
|
||||
|
||||
it('supports exact, glob and re: keys in modelMapping with exact taking precedence', () => {
|
||||
const mapping = JSON.stringify({
|
||||
'claude-sonnet-4-6': 'target-exact',
|
||||
'claude-sonnet-*': 'target-glob',
|
||||
're:^gpt-4o-mini-\\d+$': 'target-regex',
|
||||
});
|
||||
createRouteWithSingleChannel('*', mapping);
|
||||
const router = new TokenRouter();
|
||||
|
||||
const exact = router.selectChannel('claude-sonnet-4-6');
|
||||
const glob = router.selectChannel('claude-sonnet-4-7');
|
||||
const regex = router.selectChannel('gpt-4o-mini-20250101');
|
||||
|
||||
expect(exact?.actualModel).toBe('target-exact');
|
||||
expect(glob?.actualModel).toBe('target-glob');
|
||||
expect(regex?.actualModel).toBe('target-regex');
|
||||
});
|
||||
|
||||
it('matches a route by display name alias as an exposed model', () => {
|
||||
createRouteWithSingleChannel(
|
||||
're:^claude-(opus|sonnet)-4-5$',
|
||||
undefined,
|
||||
{
|
||||
displayName: 'claude-opus-4-6',
|
||||
sourceModel: 'claude-opus-4-5',
|
||||
},
|
||||
);
|
||||
const router = new TokenRouter();
|
||||
|
||||
const selected = router.selectChannel('claude-opus-4-6');
|
||||
const decision = router.explainSelection('claude-opus-4-6');
|
||||
const exposedModels = router.getAvailableModels();
|
||||
|
||||
expect(selected).toBeTruthy();
|
||||
expect(selected?.actualModel).toBe('claude-opus-4-5');
|
||||
expect(decision.actualModel).toBe('claude-opus-4-5');
|
||||
expect(exposedModels).toContain('claude-opus-4-6');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { minimatch } from 'minimatch';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { getCachedModelRoutingReferenceCost } from './modelPricingService.js';
|
||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||
|
||||
interface RouteMatch {
|
||||
route: typeof schema.tokenRoutes.$inferSelect;
|
||||
@@ -94,11 +95,147 @@ export interface RouteDecisionExplanation {
|
||||
candidates: RouteDecisionCandidate[];
|
||||
}
|
||||
|
||||
const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
||||
|
||||
type ExplainSelectionOptions = {
|
||||
excludeChannelIds?: number[];
|
||||
bypassSourceModelCheck?: boolean;
|
||||
useChannelSourceModelForCost?: boolean;
|
||||
downstreamPolicy?: DownstreamRoutingPolicy;
|
||||
};
|
||||
|
||||
type CostSignal = {
|
||||
unitCost: number;
|
||||
source: 'observed' | 'configured' | 'catalog' | 'fallback';
|
||||
};
|
||||
|
||||
export function isRegexModelPattern(pattern: string): boolean {
|
||||
return pattern.trim().toLowerCase().startsWith('re:');
|
||||
}
|
||||
|
||||
export function parseRegexModelPattern(pattern: string): RegExp | null {
|
||||
if (!isRegexModelPattern(pattern)) return null;
|
||||
const body = pattern.trim().slice(3).trim();
|
||||
if (!body) return null;
|
||||
try {
|
||||
return new RegExp(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesModelPattern(model: string, pattern: string): boolean {
|
||||
const normalizedPattern = (pattern || '').trim();
|
||||
if (!normalizedPattern) return false;
|
||||
|
||||
if (normalizedPattern === model) return true;
|
||||
|
||||
if (isRegexModelPattern(normalizedPattern)) {
|
||||
const re = parseRegexModelPattern(normalizedPattern);
|
||||
return !!re && re.test(model);
|
||||
}
|
||||
|
||||
return minimatch(model, normalizedPattern);
|
||||
}
|
||||
|
||||
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
|
||||
return (displayName || '').trim();
|
||||
}
|
||||
|
||||
function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean {
|
||||
const alias = normalizeRouteDisplayName(displayName);
|
||||
return !!alias && alias === model;
|
||||
}
|
||||
|
||||
function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean {
|
||||
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
|
||||
}
|
||||
|
||||
function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string {
|
||||
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
|
||||
}
|
||||
|
||||
function normalizeModelAlias(modelName: string): string {
|
||||
const normalized = (modelName || '').trim().toLowerCase();
|
||||
if (!normalized) return '';
|
||||
const slashIndex = normalized.lastIndexOf('/');
|
||||
if (slashIndex >= 0 && slashIndex < normalized.length - 1) {
|
||||
return normalized.slice(slashIndex + 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isModelAliasEquivalent(left: string, right: string): boolean {
|
||||
const a = normalizeModelAlias(left);
|
||||
const b = normalizeModelAlias(right);
|
||||
return !!a && !!b && a === b;
|
||||
}
|
||||
|
||||
function channelSupportsRequestedModel(channelSourceModel: string | null | undefined, requestedModel: string): boolean {
|
||||
const source = (channelSourceModel || '').trim();
|
||||
if (!source) return true;
|
||||
if (source === requestedModel) return true;
|
||||
if (isModelAliasEquivalent(source, requestedModel)) return true;
|
||||
if (matchesModelPattern(requestedModel, source)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean {
|
||||
const supportedPatterns = Array.isArray(policy.supportedModels)
|
||||
? policy.supportedModels
|
||||
: [];
|
||||
const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern));
|
||||
if (matchedSupportedPattern) return true;
|
||||
if (policy.allowedRouteIds.length > 0) return true;
|
||||
return supportedPatterns.length === 0;
|
||||
}
|
||||
|
||||
function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string {
|
||||
if (!modelMapping) return requestedModel;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(modelMapping);
|
||||
} catch {
|
||||
return requestedModel;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return requestedModel;
|
||||
}
|
||||
|
||||
const entries = Object.entries(parsed as Record<string, unknown>)
|
||||
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0) as Array<[string, string]>;
|
||||
|
||||
const exact = entries.find(([pattern]) => pattern === requestedModel);
|
||||
if (exact) return exact[1].trim();
|
||||
|
||||
for (const [pattern, target] of entries) {
|
||||
if (matchesModelPattern(requestedModel, pattern)) {
|
||||
return target.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return requestedModel;
|
||||
}
|
||||
|
||||
function normalizeChannelSourceModel(channelSourceModel: string | null | undefined): string {
|
||||
return (channelSourceModel || '').trim();
|
||||
}
|
||||
|
||||
function resolveActualModelForSelectedChannel(
|
||||
requestedModel: string,
|
||||
route: typeof schema.tokenRoutes.$inferSelect,
|
||||
mappedModel: string,
|
||||
channelSourceModel: string | null | undefined,
|
||||
): string {
|
||||
const sourceModel = normalizeChannelSourceModel(channelSourceModel);
|
||||
if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) {
|
||||
return sourceModel;
|
||||
}
|
||||
return mappedModel;
|
||||
}
|
||||
|
||||
function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal {
|
||||
const successCount = Math.max(0, candidate.channel.successCount ?? 0);
|
||||
const totalCost = Math.max(0, candidate.channel.totalCost ?? 0);
|
||||
@@ -141,23 +278,21 @@ export class TokenRouter {
|
||||
* Find matching route and select a channel for the given model.
|
||||
* Returns null if no route/channel available.
|
||||
*/
|
||||
selectChannel(requestedModel: string): SelectedChannel | null {
|
||||
const match = this.findRoute(requestedModel);
|
||||
selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): SelectedChannel | null {
|
||||
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
|
||||
|
||||
const match = this.findRoute(requestedModel, downstreamPolicy);
|
||||
if (!match) return null;
|
||||
|
||||
// Apply model mapping if configured
|
||||
let actualModel = requestedModel;
|
||||
if (match.route.modelMapping) {
|
||||
try {
|
||||
const mapping = JSON.parse(match.route.modelMapping);
|
||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
||||
} catch {}
|
||||
}
|
||||
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||
const bypassSourceModelCheck = requestedByDisplayName;
|
||||
|
||||
// Filter channels: enabled, not in cooldown, account/site active with token
|
||||
const nowIso = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const available = match.channels.filter((c) =>
|
||||
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
|
||||
c.channel.enabled &&
|
||||
c.account.status === 'active' &&
|
||||
!isSiteDisabled(c.site.status) &&
|
||||
@@ -185,10 +320,22 @@ export class TokenRouter {
|
||||
nowMs,
|
||||
RECENT_FAILURE_AVOID_SEC,
|
||||
);
|
||||
const selected = this.weightedRandomSelect(candidates, actualModel);
|
||||
const selected = this.weightedRandomSelect(
|
||||
candidates,
|
||||
requestedByDisplayName
|
||||
? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel
|
||||
: mappedModel,
|
||||
downstreamPolicy,
|
||||
);
|
||||
if (selected) {
|
||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||
if (!tokenValue) continue;
|
||||
const actualModel = resolveActualModelForSelectedChannel(
|
||||
requestedModel,
|
||||
match.route,
|
||||
mappedModel,
|
||||
selected.channel.sourceModel,
|
||||
);
|
||||
|
||||
return {
|
||||
...selected,
|
||||
@@ -205,21 +352,24 @@ export class TokenRouter {
|
||||
/**
|
||||
* Select next channel for failover (exclude already-tried channels).
|
||||
*/
|
||||
selectNextChannel(requestedModel: string, excludeChannelIds: number[]): SelectedChannel | null {
|
||||
const match = this.findRoute(requestedModel);
|
||||
selectNextChannel(
|
||||
requestedModel: string,
|
||||
excludeChannelIds: number[],
|
||||
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
|
||||
): SelectedChannel | null {
|
||||
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
|
||||
|
||||
const match = this.findRoute(requestedModel, downstreamPolicy);
|
||||
if (!match) return null;
|
||||
|
||||
let actualModel = requestedModel;
|
||||
if (match.route.modelMapping) {
|
||||
try {
|
||||
const mapping = JSON.parse(match.route.modelMapping);
|
||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
||||
} catch {}
|
||||
}
|
||||
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||
const bypassSourceModelCheck = requestedByDisplayName;
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const available = match.channels.filter((c) =>
|
||||
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
|
||||
c.channel.enabled &&
|
||||
c.account.status === 'active' &&
|
||||
!isSiteDisabled(c.site.status) &&
|
||||
@@ -244,11 +394,23 @@ export class TokenRouter {
|
||||
nowMs,
|
||||
RECENT_FAILURE_AVOID_SEC,
|
||||
);
|
||||
const selected = this.weightedRandomSelect(candidates, actualModel);
|
||||
const selected = this.weightedRandomSelect(
|
||||
candidates,
|
||||
requestedByDisplayName
|
||||
? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel
|
||||
: mappedModel,
|
||||
downstreamPolicy,
|
||||
);
|
||||
if (!selected) continue;
|
||||
|
||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||
if (!tokenValue) continue;
|
||||
const actualModel = resolveActualModelForSelectedChannel(
|
||||
requestedModel,
|
||||
match.route,
|
||||
mappedModel,
|
||||
selected.channel.sourceModel,
|
||||
);
|
||||
|
||||
return {
|
||||
...selected,
|
||||
@@ -261,8 +423,43 @@ export class TokenRouter {
|
||||
return null;
|
||||
}
|
||||
|
||||
explainSelection(requestedModel: string, excludeChannelIds: number[] = []): RouteDecisionExplanation {
|
||||
const match = this.findRoute(requestedModel);
|
||||
explainSelection(
|
||||
requestedModel: string,
|
||||
excludeChannelIds: number[] = [],
|
||||
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
|
||||
): RouteDecisionExplanation {
|
||||
const match = this.findRoute(requestedModel, downstreamPolicy);
|
||||
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
|
||||
}
|
||||
|
||||
explainSelectionForRoute(
|
||||
routeId: number,
|
||||
requestedModel: string,
|
||||
excludeChannelIds: number[] = [],
|
||||
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
|
||||
): RouteDecisionExplanation {
|
||||
const match = this.findRouteById(routeId, downstreamPolicy);
|
||||
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
|
||||
}
|
||||
|
||||
explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): RouteDecisionExplanation {
|
||||
const match = this.findRouteById(routeId, downstreamPolicy);
|
||||
const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`;
|
||||
return this.explainSelectionFromMatch(match, fallbackRequestedModel, {
|
||||
bypassSourceModelCheck: true,
|
||||
useChannelSourceModelForCost: true,
|
||||
downstreamPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
private explainSelectionFromMatch(
|
||||
match: RouteMatch | null,
|
||||
requestedModel: string,
|
||||
options: ExplainSelectionOptions = {},
|
||||
): RouteDecisionExplanation {
|
||||
const excludeChannelIds = options.excludeChannelIds ?? [];
|
||||
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
requestedModel,
|
||||
@@ -273,23 +470,27 @@ export class TokenRouter {
|
||||
};
|
||||
}
|
||||
|
||||
let actualModel = requestedModel;
|
||||
if (match.route.modelMapping) {
|
||||
try {
|
||||
const mapping = JSON.parse(match.route.modelMapping) as Record<string, string>;
|
||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
||||
} catch {}
|
||||
}
|
||||
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||
const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName;
|
||||
const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName;
|
||||
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const summary: string[] = [`命中路由:${match.route.modelPattern}`];
|
||||
if (requestedByDisplayName) {
|
||||
summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`);
|
||||
summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定');
|
||||
}
|
||||
const availableByPriority = new Map<number, RouteChannelCandidate[]>();
|
||||
const candidates: RouteDecisionCandidate[] = [];
|
||||
const candidateMap = new Map<number, RouteDecisionCandidate>();
|
||||
|
||||
for (const row of match.channels) {
|
||||
const reasonParts: string[] = [];
|
||||
if (!bypassSourceModelCheck && !channelSupportsRequestedModel(row.channel.sourceModel, requestedModel)) {
|
||||
reasonParts.push(`来源模型不匹配=${row.channel.sourceModel || ''}`);
|
||||
}
|
||||
if (!row.channel.enabled) reasonParts.push('通道禁用');
|
||||
if (row.account.status !== 'active') reasonParts.push(`账号状态=${row.account.status}`);
|
||||
if (isSiteDisabled(row.site.status)) reasonParts.push(`站点状态=${row.site.status || 'disabled'}`);
|
||||
@@ -328,7 +529,7 @@ export class TokenRouter {
|
||||
summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)');
|
||||
return {
|
||||
requestedModel,
|
||||
actualModel,
|
||||
actualModel: mappedModel,
|
||||
matched: true,
|
||||
routeId: match.route.id,
|
||||
modelPattern: match.route.modelPattern,
|
||||
@@ -356,7 +557,13 @@ export class TokenRouter {
|
||||
}
|
||||
}
|
||||
|
||||
const weighted = this.calculateWeightedSelection(filteredLayer, actualModel);
|
||||
const weighted = this.calculateWeightedSelection(
|
||||
filteredLayer,
|
||||
useChannelSourceModelForCost
|
||||
? (candidate) => (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel)
|
||||
: mappedModel,
|
||||
downstreamPolicy,
|
||||
);
|
||||
for (const detail of weighted.details) {
|
||||
const target = candidateMap.get(detail.candidate.channel.id);
|
||||
if (!target) continue;
|
||||
@@ -381,7 +588,7 @@ export class TokenRouter {
|
||||
summary.push('本次未选出通道');
|
||||
return {
|
||||
requestedModel,
|
||||
actualModel,
|
||||
actualModel: mappedModel,
|
||||
matched: true,
|
||||
routeId: match.route.id,
|
||||
modelPattern: match.route.modelPattern,
|
||||
@@ -394,7 +601,16 @@ export class TokenRouter {
|
||||
const selectedLabel = selectedChannel
|
||||
? `${selectedChannel.username} @ ${selectedChannel.siteName} / ${selectedChannel.tokenName}`
|
||||
: `channel-${selected.channel.id}`;
|
||||
const actualModel = resolveActualModelForSelectedChannel(
|
||||
requestedModel,
|
||||
match.route,
|
||||
mappedModel,
|
||||
selected.channel.sourceModel,
|
||||
);
|
||||
summary.push(`最终选择:${selectedLabel}(P${selectedPriority})`);
|
||||
if (actualModel !== mappedModel) {
|
||||
summary.push(`实际转发模型:${actualModel}`);
|
||||
}
|
||||
|
||||
return {
|
||||
requestedModel,
|
||||
@@ -449,23 +665,52 @@ export class TokenRouter {
|
||||
getAvailableModels(): string[] {
|
||||
const routes = db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
||||
return routes.map((r) => r.modelPattern);
|
||||
const exposed = routes
|
||||
.map((route) => getExposedModelNameForRoute(route).trim())
|
||||
.filter((name) => name.length > 0);
|
||||
return Array.from(new Set(exposed));
|
||||
}
|
||||
|
||||
// --- Private methods ---
|
||||
|
||||
private findRoute(model: string): RouteMatch | null {
|
||||
const routes = db.select().from(schema.tokenRoutes)
|
||||
private findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
|
||||
let routes = db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
||||
|
||||
// Find matching route by pattern
|
||||
const supportedPatterns = Array.isArray(downstreamPolicy.supportedModels)
|
||||
? downstreamPolicy.supportedModels
|
||||
: [];
|
||||
const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(model, pattern));
|
||||
|
||||
if (downstreamPolicy.allowedRouteIds.length > 0 && !matchedSupportedPattern) {
|
||||
const allowSet = new Set(downstreamPolicy.allowedRouteIds);
|
||||
routes = routes.filter((route) => allowSet.has(route.id));
|
||||
}
|
||||
|
||||
// Find matching route by model pattern or display alias.
|
||||
const matchedRoute = routes.find((r) => {
|
||||
if (r.modelPattern === model) return true; // exact match
|
||||
return minimatch(model, r.modelPattern); // glob match
|
||||
return matchesRouteRequestModel(model, r);
|
||||
});
|
||||
|
||||
if (!matchedRoute) return null;
|
||||
|
||||
return this.loadRouteMatch(matchedRoute);
|
||||
}
|
||||
|
||||
private findRouteById(routeId: number, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
|
||||
if (downstreamPolicy.allowedRouteIds.length > 0 && !downstreamPolicy.allowedRouteIds.includes(routeId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const route = db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.id, routeId))
|
||||
.get();
|
||||
if (!route || !route.enabled) return null;
|
||||
|
||||
return this.loadRouteMatch(route);
|
||||
}
|
||||
|
||||
private loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): RouteMatch {
|
||||
// Get channels with account, site and token info
|
||||
const channels = db
|
||||
.select()
|
||||
@@ -473,7 +718,7 @@ export class TokenRouter {
|
||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
||||
.where(eq(schema.routeChannels.routeId, matchedRoute.id))
|
||||
.where(eq(schema.routeChannels.routeId, route.id))
|
||||
.all();
|
||||
|
||||
const mapped = channels.map((row) => ({
|
||||
@@ -483,12 +728,13 @@ export class TokenRouter {
|
||||
token: row.account_tokens,
|
||||
}));
|
||||
|
||||
return { route: matchedRoute, channels: mapped };
|
||||
return { route, channels: mapped };
|
||||
}
|
||||
|
||||
private resolveChannelTokenValue(candidate: {
|
||||
channel: typeof schema.routeChannels.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
site?: typeof schema.sites.$inferSelect | null;
|
||||
token: typeof schema.accountTokens.$inferSelect | null;
|
||||
}): string | null {
|
||||
if (candidate.channel.tokenId) {
|
||||
@@ -499,17 +745,25 @@ export class TokenRouter {
|
||||
}
|
||||
|
||||
const fallback = candidate.account.apiToken?.trim();
|
||||
return fallback ? fallback : null;
|
||||
if (fallback) return fallback;
|
||||
|
||||
const siteApiKey = candidate.site?.apiKey?.trim();
|
||||
return siteApiKey ? siteApiKey : null;
|
||||
}
|
||||
|
||||
private weightedRandomSelect(
|
||||
candidates: RouteChannelCandidate[],
|
||||
modelName: string,
|
||||
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
||||
downstreamPolicy: DownstreamRoutingPolicy,
|
||||
) {
|
||||
return this.calculateWeightedSelection(candidates, modelName).selected;
|
||||
return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy).selected;
|
||||
}
|
||||
|
||||
private calculateWeightedSelection(candidates: RouteChannelCandidate[], modelName: string) {
|
||||
private calculateWeightedSelection(
|
||||
candidates: RouteChannelCandidate[],
|
||||
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
||||
downstreamPolicy: DownstreamRoutingPolicy,
|
||||
) {
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
selected: null as RouteChannelCandidate | null,
|
||||
@@ -529,7 +783,10 @@ export class TokenRouter {
|
||||
}
|
||||
|
||||
const { baseWeightFactor, valueScoreFactor, costWeight, balanceWeight, usageWeight } = config.routingWeights;
|
||||
const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, modelName));
|
||||
const resolveModelName = typeof modelName === 'function'
|
||||
? modelName
|
||||
: (() => modelName);
|
||||
const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, resolveModelName(candidate)));
|
||||
|
||||
const valueScores = candidates.map((c, i) => {
|
||||
const unitCost = effectiveCosts[i]?.unitCost || 1;
|
||||
@@ -559,6 +816,10 @@ export class TokenRouter {
|
||||
const contributions = candidates.map((candidate, i) => {
|
||||
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
||||
let contribution = baseContributions[i] / siteChannels;
|
||||
const siteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
|
||||
if (siteMultiplier > 0 && Number.isFinite(siteMultiplier)) {
|
||||
contribution *= siteMultiplier;
|
||||
}
|
||||
|
||||
// If upstream price is unknown and we are using fallback unit cost,
|
||||
// apply an explicit penalty so raising fallback cost meaningfully lowers probability.
|
||||
@@ -578,10 +839,11 @@ export class TokenRouter {
|
||||
? '实测'
|
||||
: (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认'));
|
||||
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
||||
const siteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
|
||||
return {
|
||||
candidate,
|
||||
probability,
|
||||
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
|
||||
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点倍率=${siteMultiplier.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user