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

- Implement tests for TokenRouter downstream policy, covering allowedRouteIds and site weight multipliers.
- Add tests for TokenRouter patterns and model mapping, including regex pattern matching and model mapping precedence.
- Create tests for BrandIcon component to verify brand detection logic.
- Develop tests for Accounts page, focusing on rebind panel behavior and refresh action on error.
- Introduce fuzzy search utility tests to validate substring and subsequence matching.
- Implement navigation focus helpers tests for building and parsing focus paths.
- Add progressive render tests to ensure correct initial and next visible counts.
- Create route model candidates index tests to validate route candidate indexing and matching logic.
This commit is contained in:
cita
2026-03-02 13:33:35 +08:00
parent 99c9e145c3
commit f6389a999a
98 changed files with 11286 additions and 801 deletions
+71
View File
@@ -113,8 +113,79 @@ function ensureSiteStatusSchema() {
`);
}
function ensureSiteProxySchema() {
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'proxy_url')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN proxy_url text;`);
}
}
function ensureRouteGroupingSchema() {
if (!tableExists('token_routes') || !tableExists('route_channels')) {
return;
}
if (!tableColumnExists('token_routes', 'display_name')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_name text;`);
}
if (!tableColumnExists('token_routes', 'display_icon')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_icon text;`);
}
if (!tableColumnExists('route_channels', 'source_model')) {
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`);
}
}
function ensureDownstreamApiKeySchema() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS downstream_api_keys (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
name text NOT NULL,
key text NOT NULL,
description text,
enabled integer DEFAULT true,
expires_at text,
max_cost real,
used_cost real DEFAULT 0,
max_requests integer,
used_requests integer DEFAULT 0,
supported_models text,
allowed_route_ids text,
site_weight_multipliers text,
last_used_at text,
created_at text DEFAULT (datetime('now')),
updated_at text DEFAULT (datetime('now'))
);
`);
sqlite.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique
ON downstream_api_keys(key);
`);
sqlite.exec(`
CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx
ON downstream_api_keys(name);
`);
sqlite.exec(`
CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx
ON downstream_api_keys(enabled);
`);
sqlite.exec(`
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
ON downstream_api_keys(expires_at);
`);
}
ensureTokenManagementSchema();
ensureSiteStatusSchema();
ensureSiteProxySchema();
ensureRouteGroupingSchema();
ensureDownstreamApiKeySchema();
export const db = drizzle(sqlite, { schema });
export { schema };
+29 -1
View File
@@ -5,7 +5,8 @@ export const sites = sqliteTable('sites', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
url: text('url').notNull(),
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api'
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
proxyUrl: text('proxy_url'),
status: text('status').notNull().default('active'), // 'active' | 'disabled'
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
sortOrder: integer('sort_order').default(0),
@@ -101,6 +102,8 @@ export const tokenModelAvailability = sqliteTable('token_model_availability', {
export const tokenRoutes = sqliteTable('token_routes', {
id: integer('id').primaryKey({ autoIncrement: true }),
modelPattern: text('model_pattern').notNull(),
displayName: text('display_name'),
displayIcon: text('display_icon'),
modelMapping: text('model_mapping'), // JSON
enabled: integer('enabled', { mode: 'boolean' }).default(true),
createdAt: text('created_at').default(sql`(datetime('now'))`),
@@ -115,6 +118,7 @@ export const routeChannels = sqliteTable('route_channels', {
routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
tokenId: integer('token_id').references(() => accountTokens.id, { onDelete: 'set null' }),
sourceModel: text('source_model'),
priority: integer('priority').default(0),
weight: integer('weight').default(10),
enabled: integer('enabled', { mode: 'boolean' }).default(true),
@@ -163,6 +167,30 @@ export const settings = sqliteTable('settings', {
value: text('value'), // JSON
});
export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
key: text('key').notNull(),
description: text('description'),
enabled: integer('enabled', { mode: 'boolean' }).default(true),
expiresAt: text('expires_at'),
maxCost: real('max_cost'),
usedCost: real('used_cost').default(0),
maxRequests: integer('max_requests'),
usedRequests: integer('used_requests').default(0),
supportedModels: text('supported_models'), // JSON array<string>
allowedRouteIds: text('allowed_route_ids'), // JSON array<number>
siteWeightMultipliers: text('site_weight_multipliers'), // JSON object { [siteId]: multiplier }
lastUsedAt: text('last_used_at'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
}, (table) => ({
keyUnique: uniqueIndex('downstream_api_keys_key_unique').on(table.key),
nameIdx: index('downstream_api_keys_name_idx').on(table.name),
enabledIdx: index('downstream_api_keys_enabled_idx').on(table.enabled),
expiresAtIdx: index('downstream_api_keys_expires_at_idx').on(table.expiresAt),
}));
export const events = sqliteTable('events', {
id: integer('id').primaryKey({ autoIncrement: true }),
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
+2
View File
@@ -16,6 +16,7 @@ import { eventsRoutes } from './routes/api/events.js';
import { taskRoutes } from './routes/api/tasks.js';
import { testRoutes } from './routes/api/test.js';
import { monitorRoutes } from './routes/api/monitor.js';
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
import { proxyRoutes } from './routes/proxy/router.js';
import { startScheduler } from './services/checkinScheduler.js';
import { buildStartupSummaryLines } from './services/startupInfo.js';
@@ -151,6 +152,7 @@ await app.register(eventsRoutes);
await app.register(taskRoutes);
await app.register(testRoutes);
await app.register(monitorRoutes);
await app.register(downstreamApiKeysRoutes);
// Register OpenAI-compatible proxy routes
await app.register(proxyRoutes);
+31 -2
View File
@@ -1,5 +1,17 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { config } from '../config.js';
import { authorizeDownstreamToken, consumeManagedKeyRequest } from '../services/downstreamApiKeyService.js';
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../services/downstreamPolicyTypes.js';
export interface ProxyAuthContext {
token: string;
source: 'managed' | 'global';
keyId: number | null;
keyName: string;
policy: DownstreamRoutingPolicy;
}
const proxyAuthContextByRequest = new WeakMap<FastifyRequest, ProxyAuthContext>();
function normalizeIp(rawIp: string | null | undefined): string {
const ip = (rawIp || '').trim();
@@ -63,8 +75,25 @@ export async function proxyAuthMiddleware(request: FastifyRequest, reply: Fastif
return;
}
if (token !== config.proxyToken) {
reply.code(403).send({ error: 'Invalid API key' });
const authResult = authorizeDownstreamToken(token);
if (!authResult.ok) {
reply.code(authResult.statusCode).send({ error: authResult.error });
return;
}
if (authResult.source === 'managed' && authResult.key) {
consumeManagedKeyRequest(authResult.key.id);
}
proxyAuthContextByRequest.set(request, {
token: authResult.token,
source: authResult.source,
keyId: authResult.key?.id ?? null,
keyName: authResult.key?.name || 'global',
policy: authResult.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY,
});
}
export function getProxyAuthContext(request: FastifyRequest): ProxyAuthContext | null {
return proxyAuthContextByRequest.get(request) || null;
}
@@ -0,0 +1,141 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const verifyTokenMock = vi.fn();
const getApiTokensMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts add requires token verification success', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-add-requires-verify-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
verifyTokenMock.mockReset();
getApiTokensMock.mockReset();
getApiTokensMock.mockResolvedValue([]);
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('rejects binding when token verification is not successful', async () => {
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
const site = db.insert(schema.sites).values({
name: 'Verify Site',
url: 'https://verify.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'invalid-token',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
});
expect(db.select().from(schema.accounts).all()).toHaveLength(0);
});
it('returns rebind hint when token verify reports invalid access token', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
const site = db.insert(schema.sites).values({
name: 'Verify Site',
url: 'https://verify.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'invalid-token',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
message: '无权进行此操作,access token 无效,请在中转站重新生成系统访问令牌后重新绑定账号',
});
expect(db.select().from(schema.accounts).all()).toHaveLength(0);
});
it('allows binding when token verification succeeds as api key', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'apikey',
models: ['gpt-4o-mini'],
});
const site = db.insert(schema.sites).values({
name: 'API Key Site',
url: 'https://apikey.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'sk-valid-key',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { tokenType?: string; apiTokenFound?: boolean };
expect(body.tokenType).toBe('apikey');
expect(body.apiTokenFound).toBe(true);
const accounts = db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(1);
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
});
});
@@ -0,0 +1,162 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
const verifyTokenMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts rebind-session api', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-rebind-session-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
verifyTokenMock.mockReset();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('rejects rebinding when token is not verified as session', async () => {
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
const site = db.insert(schema.sites).values({
name: 'Rebind Site',
url: 'https://rebind.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'linuxdo_1001',
accessToken: 'old-access-token',
status: 'expired',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: `/api/accounts/${account.id}/rebind-session`,
payload: {
accessToken: 'new-access-token',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
});
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(latest?.accessToken).toBe('old-access-token');
expect(latest?.status).toBe('expired');
});
it('returns rebind hint when verify reports invalid access token', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
const site = db.insert(schema.sites).values({
name: 'Rebind Site',
url: 'https://rebind.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'linuxdo_1001',
accessToken: 'old-access-token',
status: 'expired',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: `/api/accounts/${account.id}/rebind-session`,
payload: {
accessToken: 'new-access-token',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
message: '无权进行此操作,access token 无效,请在中转站重新生成系统访问令牌后重新绑定账号',
});
});
it('reactivates expired account when session token verification succeeds', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session',
userInfo: { username: 'linuxdo_1002' },
apiToken: 'sk-rebound-token',
});
const site = db.insert(schema.sites).values({
name: 'Rebind Site',
url: 'https://rebind.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'linuxdo_1001',
accessToken: 'old-access-token',
status: 'expired',
extraConfig: JSON.stringify({ platformUserId: 1001 }),
}).returning().get();
const response = await app.inject({
method: 'POST',
url: `/api/accounts/${account.id}/rebind-session`,
payload: {
accessToken: 'new-session-token',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success?: boolean; apiTokenFound?: boolean };
expect(body.success).toBe(true);
expect(body.apiTokenFound).toBe(true);
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(latest?.accessToken).toBe('new-session-token');
expect(latest?.apiToken).toBe('sk-rebound-token');
expect(latest?.username).toBe('linuxdo_1002');
expect(latest?.status).toBe('active');
});
});
+174 -47
View File
@@ -5,7 +5,7 @@ import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js';
import { guessPlatformUserIdFromUsername, mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
import { guessPlatformUserIdFromUsername, mergeAccountExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import { encryptAccountPassword } from '../../services/accountCredentialService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
@@ -16,6 +16,8 @@ import {
setAccountRuntimeHealth,
type RuntimeHealthState,
} from '../../services/accountHealthService.js';
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
@@ -318,7 +320,15 @@ export async function accountsRoutes(app: FastifyInstance) {
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
const result = await adapter.verifyToken(site.url, accessToken, platformUserId);
let result: any;
try {
result = await adapter.verifyToken(site.url, accessToken, platformUserId);
} catch (err: any) {
return {
success: false,
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
};
}
if (result.tokenType === 'session') {
return {
@@ -384,7 +394,7 @@ export async function accountsRoutes(app: FastifyInstance) {
for (const headers of headerVariants) {
try {
const testRes = await fetch(`${site.url}/api/user/self`, { headers });
const testRes = await fetch(`${site.url}/api/user/self`, withExplicitProxyRequestInit(site.proxyUrl, { headers }));
const bodyText = await testRes.text();
const contentType = testRes.headers.get('content-type') || '';
const reason = parseFailureReason(bodyText, contentType);
@@ -413,39 +423,159 @@ export async function accountsRoutes(app: FastifyInstance) {
};
}
return {
success: false,
message: 'Token invalid: cannot use it as session token or API key',
};
});
// Add an account (manual token input) - auto-detects token type and fetches info
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean } }>('/api/accounts', async (request) => {
const body = request.body;
const site = db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
let username = body.username;
let accessToken = body.accessToken;
let apiToken = body.apiToken;
let tokenType = 'unknown';
if (site) {
const adapter = getAdapter(site.platform);
if (adapter) {
const verifyResult = await adapter.verifyToken(site.url, body.accessToken, body.platformUserId);
tokenType = verifyResult.tokenType;
if (verifyResult.tokenType === 'session') {
// Token is a session token - can do management ops
if (!username && verifyResult.userInfo?.username) username = verifyResult.userInfo.username;
if (!apiToken && verifyResult.apiToken) apiToken = verifyResult.apiToken;
} else if (verifyResult.tokenType === 'apikey') {
// Token is an API key - store as apiToken, not accessToken
apiToken = body.accessToken;
accessToken = ''; // no session token available
}
}
}
return {
success: false,
message: 'Token invalid: cannot use it as session cookie or API key',
};
});
app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number } }>(
'/api/accounts/:id/rebind-session',
async (request, reply) => {
const accountId = Number.parseInt(request.params.id, 10);
if (!Number.isFinite(accountId) || accountId <= 0) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
const nextAccessToken = (request.body?.accessToken || '').trim();
if (!nextAccessToken) {
return reply.code(400).send({ success: false, message: '请提供新的 Session Token' });
}
const row = db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!row) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
if (!adapter) {
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
}
const bodyPlatformUserId = Number.parseInt(String(request.body?.platformUserId ?? ''), 10);
const candidatePlatformUserId = Number.isFinite(bodyPlatformUserId) && bodyPlatformUserId > 0
? bodyPlatformUserId
: resolvePlatformUserId(account.extraConfig, account.username);
let verifyResult: any;
try {
verifyResult = await adapter.verifyToken(site.url, nextAccessToken, candidatePlatformUserId);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
});
}
if (verifyResult?.tokenType !== 'session') {
return reply.code(400).send({
success: false,
message: '新的 Token 验证失败:请提供可用的 Session Token',
});
}
const nextUsernameRaw = typeof verifyResult?.userInfo?.username === 'string'
? verifyResult.userInfo.username.trim()
: '';
const nextUsername = nextUsernameRaw || account.username || '';
const inferredPlatformUserId = resolvePlatformUserId(account.extraConfig, nextUsername);
const resolvedPlatformUserId = Number.isFinite(bodyPlatformUserId) && bodyPlatformUserId > 0
? bodyPlatformUserId
: inferredPlatformUserId;
const nextApiToken = typeof verifyResult?.apiToken === 'string' && verifyResult.apiToken.trim().length > 0
? verifyResult.apiToken.trim()
: (account.apiToken || '');
const updates: Record<string, unknown> = {
accessToken: nextAccessToken,
status: 'active',
updatedAt: new Date().toISOString(),
};
if (nextUsername) {
updates.username = nextUsername;
}
if (nextApiToken) {
updates.apiToken = nextApiToken;
}
if (resolvedPlatformUserId) {
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, { platformUserId: resolvedPlatformUserId });
}
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
if (nextApiToken) {
try {
ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' });
} catch {}
}
try {
await refreshBalance(accountId);
} catch {}
try {
await refreshModelsForAccount(accountId);
rebuildTokenRoutesFromAvailability();
} catch {}
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
return {
success: true,
account: latest,
apiTokenFound: !!nextApiToken,
};
},
);
// Add an account (manual token input) - auto-detects token type and fetches info
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean } }>('/api/accounts', async (request, reply) => {
const body = request.body;
const site = db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
if (!site) {
return reply.code(400).send({ success: false, message: 'site not found' });
}
const adapter = getAdapter(site.platform);
if (!adapter) {
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
}
let username = body.username;
let accessToken = body.accessToken;
let apiToken = body.apiToken;
let verifyResult: any;
try {
verifyResult = await adapter.verifyToken(site.url, body.accessToken, body.platformUserId);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
});
}
const tokenType = verifyResult.tokenType;
if (tokenType === 'unknown') {
return reply.code(400).send({
success: false,
requiresVerification: true,
message: 'Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号',
});
}
if (tokenType === 'session') {
// Token is a session cookie - can do management ops
if (!username && verifyResult.userInfo?.username) username = verifyResult.userInfo.username;
if (!apiToken && verifyResult.apiToken) apiToken = verifyResult.apiToken;
} else if (tokenType === 'apikey') {
// Token is an API key - store as apiToken, not accessToken
apiToken = body.accessToken;
accessToken = ''; // no session cookie available
}
// Store platformUserId in extraConfig for NewAPI sites that need it
const resolvedPlatformUserId =
@@ -472,17 +602,14 @@ export async function accountsRoutes(app: FastifyInstance) {
} catch { }
}
if (tokenType === 'session' && accessToken && site) {
try {
const adapter = getAdapter(site.platform);
if (adapter) {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
if (syncedTokens.length > 0) {
syncTokensFromUpstream(result.id, syncedTokens);
}
}
} catch { }
}
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
if (syncedTokens.length > 0) {
syncTokensFromUpstream(result.id, syncedTokens);
}
} catch { }
}
// Try to refresh balance
if (tokenType === 'session') {
@@ -0,0 +1,118 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const verifyTokenMock = vi.fn();
const undiciFetchMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => undiciFetchMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts verify-token shield detection', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-verify-shield-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
verifyTokenMock.mockReset();
undiciFetchMock.mockReset();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns rebind hint when verify-token reports invalid access token', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('invalid access token'));
const site = db.insert(schema.sites).values({
name: 'AnyRouter',
url: 'https://anyrouter.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'session-or-cookie-token',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: false,
message: 'invalid access token,请在中转站重新生成系统访问令牌后重新绑定账号',
});
});
it('returns shieldBlocked when upstream /api/user/self responds with challenge html', async () => {
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
undiciFetchMock.mockResolvedValue({
text: async () => '<html><script>var arg1="ABC123";</script></html>',
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null),
},
});
const site = db.insert(schema.sites).values({
name: 'AnyRouter',
url: 'https://anyrouter.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'session-or-cookie-token',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: false,
shieldBlocked: true,
});
expect(undiciFetchMock).toHaveBeenCalled();
});
});
+224
View File
@@ -0,0 +1,224 @@
import { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import {
getDownstreamApiKeyById,
listDownstreamApiKeys,
normalizeDownstreamApiKeyPayload,
toDownstreamApiKeyPolicyView,
toPersistenceJson,
} from '../../services/downstreamApiKeyService.js';
function parseRouteId(raw: string): number | null {
const id = Number.parseInt(raw, 10);
if (!Number.isFinite(id) || id <= 0) return null;
return id;
}
function validateKeyShape(key: string): boolean {
return key.startsWith('sk-') && key.length >= 6;
}
function looksLikeUniqueViolation(error: unknown): boolean {
const message = (error as Error | undefined)?.message || '';
return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
}
export async function downstreamApiKeysRoutes(app: FastifyInstance) {
app.get('/api/downstream-keys', async () => {
return {
success: true,
items: listDownstreamApiKeys(),
};
});
app.post<{
Body: {
name?: unknown;
key?: unknown;
description?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
maxRequests?: unknown;
supportedModels?: unknown;
allowedRouteIds?: unknown;
siteWeightMultipliers?: unknown;
};
}>('/api/downstream-keys', async (request, reply) => {
let normalized: ReturnType<typeof normalizeDownstreamApiKeyPayload>;
try {
normalized = normalizeDownstreamApiKeyPayload(request.body || {});
} catch (error: unknown) {
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
}
if (!normalized.name) {
return reply.code(400).send({ success: false, message: 'name 不能为空' });
}
if (!normalized.key) {
return reply.code(400).send({ success: false, message: 'key 不能为空' });
}
if (!validateKeyShape(normalized.key)) {
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
}
const nowIso = new Date().toISOString();
try {
const inserted = db.insert(schema.downstreamApiKeys).values({
name: normalized.name,
key: normalized.key,
description: normalized.description,
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
usedCost: 0,
maxRequests: normalized.maxRequests,
usedRequests: 0,
supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
createdAt: nowIso,
updatedAt: nowIso,
}).returning().get();
return {
success: true,
item: toDownstreamApiKeyPolicyView(inserted),
};
} catch (error: unknown) {
if (looksLikeUniqueViolation(error)) {
return reply.code(409).send({ success: false, message: 'API key 已存在' });
}
return reply.code(500).send({ success: false, message: (error as Error)?.message || '创建失败' });
}
});
app.put<{
Params: { id: string };
Body: {
name?: unknown;
key?: unknown;
description?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
maxRequests?: unknown;
supportedModels?: unknown;
allowedRouteIds?: unknown;
siteWeightMultipliers?: unknown;
};
}>('/api/downstream-keys/:id', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, id))
.get();
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
const existingView = toDownstreamApiKeyPolicyView(existing);
let normalized: ReturnType<typeof normalizeDownstreamApiKeyPayload>;
try {
normalized = normalizeDownstreamApiKeyPayload({
name: request.body?.name ?? existing.name,
key: request.body?.key ?? existing.key,
description: request.body?.description ?? existing.description,
enabled: request.body?.enabled ?? existing.enabled,
expiresAt: request.body?.expiresAt ?? existing.expiresAt,
maxCost: request.body?.maxCost ?? existing.maxCost,
maxRequests: request.body?.maxRequests ?? existing.maxRequests,
supportedModels: request.body?.supportedModels ?? existingView.supportedModels,
allowedRouteIds: request.body?.allowedRouteIds ?? existingView.allowedRouteIds,
siteWeightMultipliers: request.body?.siteWeightMultipliers ?? existingView.siteWeightMultipliers,
});
} catch (error: unknown) {
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
}
if (!normalized.name) {
return reply.code(400).send({ success: false, message: 'name 不能为空' });
}
if (!normalized.key) {
return reply.code(400).send({ success: false, message: 'key 不能为空' });
}
if (!validateKeyShape(normalized.key)) {
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
}
const nowIso = new Date().toISOString();
try {
db.update(schema.downstreamApiKeys).set({
name: normalized.name,
key: normalized.key,
description: normalized.description,
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
maxRequests: normalized.maxRequests,
supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, id)).run();
const updated = getDownstreamApiKeyById(id);
return {
success: true,
item: updated,
};
} catch (error: unknown) {
if (looksLikeUniqueViolation(error)) {
return reply.code(409).send({ success: false, message: 'API key 已存在' });
}
return reply.code(500).send({ success: false, message: (error as Error)?.message || '更新失败' });
}
});
app.post<{ Params: { id: string } }>('/api/downstream-keys/:id/reset-usage', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = getDownstreamApiKeyById(id);
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
db.update(schema.downstreamApiKeys).set({
usedCost: 0,
usedRequests: 0,
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
return {
success: true,
item: getDownstreamApiKeyById(id),
};
});
app.delete<{ Params: { id: string } }>('/api/downstream-keys/:id', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = getDownstreamApiKeyById(id);
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
db.delete(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, id))
.run();
return { success: true };
});
}
@@ -102,6 +102,36 @@ describe('settings and auth events', () => {
expect(body.message).toContain('sk-');
});
it('rejects invalid bark url when bark channel is enabled', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
barkEnabled: true,
barkUrl: 'juricek.chen@gmail.com',
},
});
expect(response.statusCode).toBe(400);
const body = response.json() as { message?: string };
expect(body.message).toContain('Bark URL');
});
it('rejects invalid webhook url when webhook channel is enabled', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
webhookEnabled: true,
webhookUrl: 'not-a-url',
},
});
expect(response.statusCode).toBe(400);
const body = response.json() as { message?: string };
expect(body.message).toContain('Webhook URL');
});
it('persists and returns routing fallback unit cost from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
+43
View File
@@ -96,6 +96,17 @@ function toStringList(value: unknown): string[] {
return [];
}
function isValidHttpUrl(raw: string): boolean {
const value = String(raw || '').trim();
if (!value) return false;
try {
const parsed = new URL(value);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function applyImportedSettingToRuntime(key: string, value: unknown) {
switch (key) {
case 'checkin_cron': {
@@ -254,6 +265,38 @@ export async function settingsRoutes(app: FastifyInstance) {
const changedLabels: string[] = [];
const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
const webhookTouched = body.webhookUrl !== undefined || body.webhookEnabled !== undefined;
const nextWebhookUrl = body.webhookUrl !== undefined
? String(body.webhookUrl || '').trim()
: config.webhookUrl;
const nextWebhookEnabled = body.webhookEnabled !== undefined
? !!body.webhookEnabled
: config.webhookEnabled;
if (webhookTouched && nextWebhookEnabled) {
if (!nextWebhookUrl) {
return reply.code(400).send({ success: false, message: 'Webhook URL 不能为空(启用 Webhook 时)' });
}
if (!isValidHttpUrl(nextWebhookUrl)) {
return reply.code(400).send({ success: false, message: 'Webhook URL 无效,请填写 http/https 地址' });
}
}
const barkTouched = body.barkUrl !== undefined || body.barkEnabled !== undefined;
const nextBarkUrl = body.barkUrl !== undefined
? String(body.barkUrl || '').trim()
: config.barkUrl;
const nextBarkEnabled = body.barkEnabled !== undefined
? !!body.barkEnabled
: config.barkEnabled;
if (barkTouched && nextBarkEnabled) {
if (!nextBarkUrl) {
return reply.code(400).send({ success: false, message: 'Bark URL 不能为空(启用 Bark 时)' });
}
if (!isValidHttpUrl(nextBarkUrl)) {
return reply.code(400).send({ success: false, message: 'Bark URL 无效,请填写 http/https 地址' });
}
}
if (body.checkinCron !== undefined) {
if (!cron.validate(body.checkinCron)) {
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
@@ -0,0 +1,71 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
type DbModule = typeof import('../../db/index.js');
describe('sites proxy url settings', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-proxy-url-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(() => {
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('stores proxy url when creating a site', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890',
},
});
expect(response.statusCode).toBe(200);
const payload = response.json() as { proxyUrl?: string | null };
expect(payload.proxyUrl).toBe('http://127.0.0.1:7890');
});
it('rejects invalid proxy url', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'not-a-url',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl');
});
});
+17 -3
View File
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
@@ -48,12 +49,16 @@ export async function sitesRoutes(app: FastifyInstance) {
});
// Add a site
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites', async (request, reply) => {
const { name, url, platform, apiKey, status, isPinned, sortOrder } = request.body;
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; proxyUrl?: string | null; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites', async (request, reply) => {
const { name, url, platform, apiKey, proxyUrl, status, isPinned, sortOrder } = request.body;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
}
const parsedProxyUrl = parseSiteProxyUrlInput(proxyUrl);
if (!parsedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
}
const normalizedPinned = normalizePinnedFlag(isPinned);
if (isPinned !== undefined && normalizedPinned === null) {
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
@@ -79,15 +84,17 @@ export async function sitesRoutes(app: FastifyInstance) {
url: url.replace(/\/+$/, ''),
platform: detectedPlatform,
apiKey,
proxyUrl: parsedProxyUrl.proxyUrl,
status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
}).returning().get();
invalidateSiteProxyCache();
return result;
});
// Update a site
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites/:id', async (request, reply) => {
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; proxyUrl?: string | null; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites/:id', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
@@ -104,6 +111,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
}
const parsedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl);
if (!parsedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
}
const normalizedPinned = normalizePinnedFlag(body.isPinned);
if (body.isPinned !== undefined && normalizedPinned === null) {
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
@@ -117,11 +128,13 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform;
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl;
if (body.status !== undefined) updates.status = normalizedStatus;
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
updates.updatedAt = new Date().toISOString();
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
invalidateSiteProxyCache();
if (body.status !== undefined && normalizedStatus) {
const now = new Date().toISOString();
@@ -167,6 +180,7 @@ export async function sitesRoutes(app: FastifyInstance) {
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
const id = parseInt(request.params.id);
db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
invalidateSiteProxyCache();
return { success: true };
});
@@ -99,4 +99,137 @@ describe('POST /api/routes/decision/batch', () => {
expect(body.decisions['gpt-4o-mini']?.candidates.length).toBeGreaterThan(0);
expect(body.decisions['unknown-model']?.matched).toBe(false);
});
it('returns decisions scoped by route id to avoid wildcard channel mismatch', async () => {
const site = db.insert(schema.sites).values({
name: 'wildcard-site',
url: 'https://wildcard-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'wildcard-user',
accessToken: 'wildcard-access',
apiToken: 'wildcard-api',
status: 'active',
}).returning().get();
const exactRoute = db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-6',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
routeId: exactRoute.id,
accountId: account.id,
tokenId: null,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 10,
enabled: true,
}).run();
const wildcardRoute = db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
enabled: true,
}).returning().get();
const wildcardChannel = db.insert(schema.routeChannels).values({
routeId: wildcardRoute.id,
accountId: account.id,
tokenId: null,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/routes/decision/by-route/batch',
payload: {
items: [{ routeId: wildcardRoute.id, model: 'claude-opus-4-6' }],
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success: boolean;
decisions: Record<string, Record<string, { routeId?: number; matched: boolean; candidates: Array<{ channelId: number }> }>>;
};
expect(body.success).toBe(true);
const decision = body.decisions[String(wildcardRoute.id)]?.['claude-opus-4-6'];
expect(decision?.matched).toBe(true);
expect(decision?.routeId).toBe(wildcardRoute.id);
expect(decision?.candidates.some((candidate) => candidate.channelId === wildcardChannel.id)).toBe(true);
});
it('returns route-wide wildcard probabilities normalized to 100 across all channels', async () => {
const site = db.insert(schema.sites).values({
name: 'route-wide-site',
url: 'https://route-wide-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'route-wide-user',
accessToken: 'route-wide-access',
apiToken: 'route-wide-api',
status: 'active',
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
enabled: true,
}).returning().get();
const channelA = db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const channelB = db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
sourceModel: 'claude-sonnet-4-6',
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/routes/decision/route-wide/batch',
payload: { routeIds: [route.id] },
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success: boolean;
decisions: Record<string, {
matched: boolean;
routeId?: number;
candidates: Array<{ channelId: number; probability: number }>;
}>;
};
expect(body.success).toBe(true);
const decision = body.decisions[String(route.id)];
expect(decision?.matched).toBe(true);
expect(decision?.routeId).toBe(route.id);
expect(decision?.candidates.some((candidate) => candidate.channelId === channelA.id)).toBe(true);
expect(decision?.candidates.some((candidate) => candidate.channelId === channelB.id)).toBe(true);
const totalProbability = (decision?.candidates || []).reduce((sum, candidate) => sum + (candidate.probability || 0), 0);
expect(totalProbability).toBeCloseTo(100, 1);
});
});
+280 -9
View File
@@ -2,11 +2,14 @@
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
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);
}
function getDefaultTokenId(accountId: number): number | null {
@@ -16,17 +19,36 @@ function getDefaultTokenId(accountId: number): number | null {
return token?.id ?? null;
}
function canonicalModelAlias(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 = canonicalModelAlias(left);
const b = canonicalModelAlias(right);
return !!a && !!b && a === b;
}
function tokenSupportsModel(tokenId: number, modelName: string): boolean {
const row = db.select().from(schema.tokenModelAvailability)
const rows = db.select().from(schema.tokenModelAvailability)
.where(
and(
eq(schema.tokenModelAvailability.tokenId, tokenId),
eq(schema.tokenModelAvailability.modelName, modelName),
eq(schema.tokenModelAvailability.available, true),
),
)
.get();
return !!row;
.all();
return rows.some((row) => {
const availableModelName = row.modelName?.trim();
if (!availableModelName) return false;
return availableModelName === modelName || isModelAliasEquivalent(availableModelName, modelName);
});
}
function checkTokenBelongsToAccount(tokenId: number, accountId: number): boolean {
@@ -36,6 +58,117 @@ function checkTokenBelongsToAccount(tokenId: number, accountId: number): boolean
return !!row;
}
function getPatternTokenCandidates(modelPattern: string): Array<{ tokenId: number; accountId: number; sourceModel: string }> {
const rows = db.select().from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
if (!matchesModelPattern(modelName, modelPattern)) continue;
result.push({
tokenId: row.account_tokens.id,
accountId: row.accounts.id,
sourceModel: modelName,
});
}
return result;
}
function getMatchedExactRouteChannelCandidates(modelPattern: string): Array<{
tokenId: number | null;
accountId: number;
sourceModel: string;
priority: number;
weight: number;
enabled: boolean;
manualOverride: boolean;
}> {
const matchedRoutes = db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all()
.filter((route) => isExactModelPattern(route.modelPattern) && matchesModelPattern(route.modelPattern, modelPattern));
if (matchedRoutes.length === 0) return [];
const routeMap = new Map<number, typeof matchedRoutes[number]>();
for (const route of matchedRoutes) routeMap.set(route.id, route);
const channels = db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.routeId, matchedRoutes.map((route) => route.id)))
.all();
return channels.map((channel) => ({
tokenId: channel.tokenId ?? null,
accountId: channel.accountId,
sourceModel: (channel.sourceModel || routeMap.get(channel.routeId)?.modelPattern || '').trim(),
priority: channel.priority ?? 0,
weight: channel.weight ?? 10,
enabled: !!channel.enabled,
manualOverride: !!channel.manualOverride,
})).filter((candidate) => candidate.sourceModel.length > 0);
}
function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): number {
const routeCandidates = getMatchedExactRouteChannelCandidates(modelPattern);
const availabilityCandidates = getPatternTokenCandidates(modelPattern).map((candidate) => ({
tokenId: candidate.tokenId,
accountId: candidate.accountId,
sourceModel: candidate.sourceModel,
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
}));
const candidates = [...routeCandidates, ...availabilityCandidates];
if (candidates.length === 0) return 0;
const existingChannels = db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, routeId))
.all();
const existingPairs = new Set<string>(
existingChannels
.map((channel) => {
const tokenId = typeof channel.tokenId === 'number' && Number.isFinite(channel.tokenId) ? channel.tokenId : 0;
const sourceModel = (channel.sourceModel || '').trim().toLowerCase();
return `${channel.accountId}::${tokenId}::${sourceModel}`;
}),
);
let created = 0;
for (const candidate of candidates) {
const tokenId = typeof candidate.tokenId === 'number' && Number.isFinite(candidate.tokenId) ? candidate.tokenId : 0;
const pairKey = `${candidate.accountId}::${tokenId}::${candidate.sourceModel.trim().toLowerCase()}`;
if (existingPairs.has(pairKey)) continue;
db.insert(schema.routeChannels).values({
routeId,
accountId: candidate.accountId,
tokenId: candidate.tokenId,
sourceModel: candidate.sourceModel,
priority: candidate.priority,
weight: candidate.weight,
enabled: candidate.enabled,
manualOverride: candidate.manualOverride,
}).run();
existingPairs.add(pairKey);
created += 1;
}
return created;
}
type BatchChannelPriorityUpdate = {
id: number;
priority: number;
@@ -45,6 +178,17 @@ type BatchRouteDecisionModels = {
models: string[];
};
type BatchRouteDecisionRouteModels = {
items: Array<{
routeId: number;
model: string;
}>;
};
type BatchRouteWideDecisionRouteIds = {
routeIds: number[];
};
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' };
@@ -112,6 +256,75 @@ function parseBatchRouteDecisionModels(input: unknown): { ok: true; models: stri
return { ok: true, models: normalized };
}
function parseBatchRouteDecisionRouteModels(
input: unknown,
): { ok: true; items: Array<{ routeId: number; model: string }> } | { ok: false; message: string } {
if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' };
}
const items = (input as BatchRouteDecisionRouteModels).items;
if (!Array.isArray(items) || items.length === 0) {
return { ok: false, message: 'items 必须是非空数组' };
}
const dedupe = new Set<string>();
const normalized: Array<{ routeId: number; model: string }> = [];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const routeIdRaw = (item as { routeId?: unknown }).routeId;
const modelRaw = (item as { model?: unknown }).model;
if (typeof routeIdRaw !== 'number' || !Number.isFinite(routeIdRaw)) continue;
if (typeof modelRaw !== 'string') continue;
const routeId = Math.trunc(routeIdRaw);
const model = modelRaw.trim();
if (routeId <= 0 || !model) continue;
const key = `${routeId}::${model}`;
if (dedupe.has(key)) continue;
dedupe.add(key);
normalized.push({ routeId, model });
if (normalized.length >= 500) break;
}
if (normalized.length === 0) {
return { ok: false, message: 'items 中没有有效 routeId/model' };
}
return { ok: true, items: normalized };
}
function parseBatchRouteWideDecisionRouteIds(
input: unknown,
): { ok: true; routeIds: number[] } | { ok: false; message: string } {
if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' };
}
const routeIds = (input as BatchRouteWideDecisionRouteIds).routeIds;
if (!Array.isArray(routeIds) || routeIds.length === 0) {
return { ok: false, message: 'routeIds 必须是非空数组' };
}
const dedupe = new Set<number>();
const normalized: number[] = [];
for (const raw of routeIds) {
if (typeof raw !== 'number' || !Number.isFinite(raw)) continue;
const routeId = Math.trunc(raw);
if (routeId <= 0 || dedupe.has(routeId)) continue;
dedupe.add(routeId);
normalized.push(routeId);
if (normalized.length >= 500) break;
}
if (normalized.length === 0) {
return { ok: false, message: 'routeIds 中没有有效 routeId' };
}
return { ok: true, routeIds: normalized };
}
export async function tokensRoutes(app: FastifyInstance) {
// List all routes
app.get('/api/routes', async () => {
@@ -177,14 +390,49 @@ export async function tokensRoutes(app: FastifyInstance) {
return { success: true, decisions };
});
app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => {
const parsed = parseBatchRouteDecisionRouteModels(request.body);
if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const decisions: Record<string, Record<string, ReturnType<typeof tokenRouter.explainSelection>>> = {};
for (const item of parsed.items) {
const routeKey = String(item.routeId);
if (!decisions[routeKey]) decisions[routeKey] = {};
decisions[routeKey][item.model] = tokenRouter.explainSelectionForRoute(item.routeId, item.model);
}
return { success: true, decisions };
});
app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => {
const parsed = parseBatchRouteWideDecisionRouteIds(request.body);
if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const decisions: Record<string, ReturnType<typeof tokenRouter.explainSelection>> = {};
for (const routeId of parsed.routeIds) {
decisions[String(routeId)] = tokenRouter.explainSelectionRouteWide(routeId);
}
return { success: true, decisions };
});
// Create a route
app.post<{ Body: { modelPattern: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
const body = request.body;
return db.insert(schema.tokenRoutes).values({
const route = db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern,
displayName: body.displayName,
displayIcon: body.displayIcon,
modelMapping: body.modelMapping,
enabled: body.enabled ?? true,
}).returning().get();
populateRouteChannelsByModelPattern(route.id, body.modelPattern);
return route;
});
// Update a route
@@ -193,6 +441,8 @@ export async function tokensRoutes(app: FastifyInstance) {
const body = request.body as Record<string, unknown>;
const updates: Record<string, unknown> = {};
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (body.modelPattern !== undefined) updates.modelPattern = body.modelPattern;
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
if (body.enabled !== undefined) updates.enabled = body.enabled;
@@ -210,7 +460,7 @@ export async function tokensRoutes(app: FastifyInstance) {
});
// Add a channel to a route
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
@@ -219,6 +469,9 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const sourceModel = typeof body.sourceModel === 'string'
? body.sourceModel.trim()
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
const effectiveTokenId = body.tokenId ?? getDefaultTokenId(body.accountId);
if (body.tokenId && !checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
@@ -229,10 +482,23 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
}
const duplicate = db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, routeId))
.all()
.some((channel) =>
channel.accountId === body.accountId
&& (channel.tokenId ?? null) === (body.tokenId ?? null)
&& (channel.sourceModel || '').trim().toLowerCase() === sourceModel.toLowerCase(),
);
if (duplicate) {
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
}
return db.insert(schema.routeChannels).values({
routeId,
accountId: body.accountId,
tokenId: body.tokenId,
sourceModel: sourceModel || null,
priority: body.priority ?? 0,
weight: body.weight ?? 10,
}).returning().get();
@@ -299,6 +565,11 @@ export async function tokensRoutes(app: FastifyInstance) {
}
const updates: Record<string, unknown> = { manualOverride: true };
if (body.sourceModel !== undefined) {
if (body.sourceModel === null) updates.sourceModel = null;
else updates.sourceModel = String(body.sourceModel).trim() || null;
}
for (const key of ['priority', 'weight', 'enabled', 'tokenId']) {
if (body[key] !== undefined) updates[key] = body[key];
}
+210
View File
@@ -448,6 +448,89 @@ describe('chat proxy stream behavior', () => {
expect(forwardedBody.thinking).toEqual({ type: 'enabled', budget_tokens: 1024 });
});
it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_headers_adaptive',
type: 'message',
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
thinking: { type: 'adaptive', budget_tokens: 1024 },
},
});
expect(response.statusCode).toBe(200);
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.thinking).toEqual({ type: 'enabled', budget_tokens: 1024 });
});
it('retries /v1/messages with normalized Claude body when upstream says messages is required', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
type: '<nil>',
message: 'messages is required',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'msg_retry_ok',
type: 'message',
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'ok after normalized fallback' }],
stop_reason: 'end_turn',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'hello' },
],
},
],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock.mock.calls.length).toBe(2);
const [_firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [_secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
const firstBody = JSON.parse(firstOptions.body);
const secondBody = JSON.parse(secondOptions.body);
expect(Array.isArray(firstBody.messages)).toBe(true);
expect(Array.isArray(secondBody.messages)).toBe(true);
expect(typeof secondBody.messages[0]?.content).toBe('string');
expect(secondBody.messages[0]?.content).toContain('hello');
});
it('passes through Claude tool_use SSE events on /v1/messages for CLI tool execution', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
@@ -805,6 +888,133 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/messages');
});
it('forces openai platform to use /v1/chat/completions for claude downstream requests', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-openai',
actualModel: 'gpt-4o-mini',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-for-claude-downstream',
object: 'chat.completion',
created: 1_706_000_003,
model: 'gpt-4o-mini',
choices: [{
index: 0,
message: { role: 'assistant', content: 'openai endpoint selected' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'gpt-4o-mini',
max_tokens: 128,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/chat/completions');
expect(targetUrl).not.toContain('/v1/messages');
});
it('forces claude platform to use /v1/messages with x-api-key auth for openai downstream requests', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-claude',
actualModel: 'claude-sonnet-4-5-20250929',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_claude_upstream',
type: 'message',
model: 'claude-sonnet-4-5-20250929',
content: [{ type: 'text', text: 'claude endpoint selected' }],
stop_reason: 'end_turn',
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-sonnet-4-5-20250929',
stream: false,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/messages');
expect(options.headers['x-api-key']).toBe('sk-claude');
expect(options.headers['anthropic-version']).toBeTruthy();
expect(options.headers.Authorization).toBeUndefined();
});
it('routes gemini platform to OpenAI-compatible upstream endpoint path', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'gemini-site', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'gemini-key',
actualModel: 'gemini-2.5-flash',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-gemini-openai-compat',
object: 'chat.completion',
created: 1_706_000_004,
model: 'gemini-2.5-flash',
choices: [{
index: 0,
message: { role: 'assistant', content: 'gemini endpoint selected' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 6, completion_tokens: 4, total_tokens: 10 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gemini-2.5-flash',
stream: false,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1beta/openai/chat/completions');
expect(options.headers.Authorization).toBe('Bearer gemini-key');
});
it('chooses /v1/messages upstream when catalog indicates messages-only endpoint support', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
+66 -5
View File
@@ -9,6 +9,7 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import {
type DownstreamFormat,
createStreamTransformContext,
@@ -27,6 +28,11 @@ import {
isEndpointDowngradeError,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
import {
ensureModelAllowedForDownstreamKey,
getDownstreamRoutingPolicy,
recordDownstreamCostUsage,
} from './downstreamPolicy.js';
const MAX_RETRIES = 2;
const CLAUDE_SSE_EVENT_NAMES = new Set([
@@ -44,6 +50,18 @@ function withUpstreamPath(path: string, message: string): string {
return `[upstream:${path}] ${message}`;
}
function shouldRetryClaudeMessagesWithNormalizedBody(
downstreamFormat: DownstreamFormat,
endpointPath: string,
status: number,
upstreamErrorText: string,
): boolean {
if (downstreamFormat !== 'claude') return false;
if (!endpointPath.includes('/v1/messages')) return false;
if (status < 400 || status >= 500) return false;
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -116,18 +134,20 @@ async function handleChatProxyRequest(
}
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!;
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
@@ -165,6 +185,8 @@ async function handleChatProxyRequest(
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: upstreamBody,
downstreamFormat,
claudeOriginalBody,
@@ -174,11 +196,11 @@ async function handleChatProxyRequest(
const targetUrl = `${selected.site.url}${endpointRequest.path}`;
startTime = Date.now();
const response = await fetch(targetUrl, {
const response = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
method: 'POST',
headers: endpointRequest.headers,
body: JSON.stringify(endpointRequest.body),
});
}));
if (response.ok) {
upstream = response;
@@ -187,6 +209,42 @@ async function handleChatProxyRequest(
}
const rawErrText = await response.text().catch(() => 'unknown error');
if (shouldRetryClaudeMessagesWithNormalizedBody(downstreamFormat, endpointRequest.path, response.status, rawErrText)) {
const normalizedClaudeRequest = buildUpstreamEndpointRequest({
endpoint: endpointCandidates[endpointIndex],
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: upstreamBody,
downstreamFormat,
claudeOriginalBody: undefined,
downstreamHeaders: request.headers as Record<string, unknown>,
});
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
const normalizedResponse = await fetch(normalizedTargetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
method: 'POST',
headers: normalizedClaudeRequest.headers,
body: JSON.stringify(normalizedClaudeRequest.body),
}));
if (normalizedResponse.ok) {
upstream = normalizedResponse;
successfulUpstreamPath = normalizedClaudeRequest.path;
break;
}
const normalizedErrText = await normalizedResponse.text().catch(() => 'unknown error');
finalStatus = normalizedResponse.status;
finalErrText = withUpstreamPath(
normalizedClaudeRequest.path,
`normalized-claude-body fallback failed: ${normalizedErrText}`,
);
break;
}
const errText = withUpstreamPath(endpointRequest.path, rawErrText);
const shouldDowngradeEndpoint = (
endpointIndex < endpointCandidates.length - 1
@@ -332,6 +390,7 @@ async function handleChatProxyRequest(
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
requestedModel,
@@ -470,6 +529,7 @@ async function handleChatProxyRequest(
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
requestedModel,
@@ -530,6 +590,7 @@ async function handleChatProxyRequest(
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
requestedModel,
+29 -23
View File
@@ -9,30 +9,34 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
const MAX_RETRIES = 2;
export async function completionsProxyRoute(app: FastifyInstance) {
app.post('/v1/completions', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = body?.model;
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
const isStream = body.stream === true;
const requestedModel = body?.model;
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
}
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
await reportProxyAllFailed({
@@ -51,14 +55,14 @@ export async function completionsProxyRoute(app: FastifyInstance) {
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
});
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}));
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
@@ -159,6 +163,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
@@ -197,6 +202,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
@@ -0,0 +1,35 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from '../../services/downstreamApiKeyService.js';
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy {
return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY;
}
export function ensureModelAllowedForDownstreamKey(
request: FastifyRequest,
reply: FastifyReply,
requestedModel: string,
): boolean {
const authContext = getProxyAuthContext(request);
if (!authContext) return true;
if (isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
return true;
}
reply.code(403).send({
error: {
message: `Model not allowed for this API key: ${requestedModel}`,
type: 'permission_error',
},
});
return false;
}
export function recordDownstreamCostUsage(request: FastifyRequest, estimatedCost: number): void {
const authContext = getProxyAuthContext(request);
if (!authContext || authContext.keyId === null) return;
recordManagedKeyCostUsage(authContext.keyId, estimatedCost);
}
+28 -23
View File
@@ -9,29 +9,33 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
const MAX_RETRIES = 2;
export async function embeddingsProxyRoute(app: FastifyInstance) {
app.post('/v1/embeddings', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = body?.model;
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
const excludeChannelIds: number[] = [];
const requestedModel = body?.model;
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
}
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
await reportProxyAllFailed({
@@ -48,14 +52,14 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
});
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}));
const text = await upstream.text();
if (!upstream.ok) {
@@ -115,6 +119,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', upstream.status, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
+35 -30
View File
@@ -7,25 +7,29 @@ import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertSe
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
const MAX_RETRIES = 2;
export async function imagesProxyRoute(app: FastifyInstance) {
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = body?.model || 'gpt-image-1';
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
}
const body = request.body as any;
const requestedModel = body?.model || 'gpt-image-1';
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
await reportProxyAllFailed({
@@ -44,14 +48,14 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
});
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}));
const text = await upstream.text();
if (!upstream.ok) {
@@ -80,17 +84,18 @@ export async function imagesProxyRoute(app: FastifyInstance) {
try { data = JSON.parse(text); } catch { data = { data: [] }; }
const latency = Date.now() - startTime;
const estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
const estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost);
return reply.code(upstream.status).send(data);
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost);
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
+15 -1
View File
@@ -2,9 +2,13 @@ import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq } from 'drizzle-orm';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
export async function modelsProxyRoute(app: FastifyInstance) {
app.get('/v1/models', async (request) => {
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const readModels = () => {
const rows = db.select({ modelName: schema.modelAvailability.modelName })
.from(schema.modelAvailability)
@@ -18,7 +22,17 @@ export async function modelsProxyRoute(app: FastifyInstance) {
),
)
.all();
return Array.from(new Set(rows.map((r) => r.modelName))).sort();
const routeAliases = db.select({ displayName: schema.tokenRoutes.displayName })
.from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all()
.map((row) => (row.displayName || '').trim())
.filter((name) => name.length > 0);
const deduped = Array.from(new Set([
...rows.map((r) => r.modelName),
...routeAliases,
])).sort();
return deduped.filter((modelName) => isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy));
};
let models = readModels();
+13 -5
View File
@@ -9,6 +9,7 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import {
createStreamTransformContext,
normalizeUpstreamFinalResponse,
@@ -22,6 +23,7 @@ import {
resolveUpstreamEndpointCandidates,
type UpstreamEndpoint,
} from './upstreamEndpoint.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
const MAX_RETRIES = 2;
@@ -613,6 +615,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
@@ -620,12 +624,12 @@ export async function responsesProxyRoute(app: FastifyInstance) {
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
@@ -669,6 +673,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: openAiBody,
downstreamFormat: 'responses',
responsesOriginalBody: body,
@@ -676,11 +682,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
});
const targetUrl = `${selected.site.url}${endpointRequest.path}`;
const response = await fetch(targetUrl, {
const response = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
method: 'POST',
headers: endpointRequest.headers,
body: JSON.stringify(endpointRequest.body),
});
}));
if (response.ok) {
upstream = response;
@@ -858,6 +864,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
@@ -909,6 +916,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
+91 -8
View File
@@ -26,6 +26,10 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizePlatformName(platform: unknown): string {
return asTrimmedString(platform).toLowerCase();
}
function headerValueToString(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
@@ -167,6 +171,28 @@ function sanitizeMessagesBodyForAnthropic(body: Record<string, unknown>): Record
if (hasTemperature && hasTopP) {
delete sanitized.top_p;
}
// Claude Code may send thinking.type = "adaptive". Many Anthropic-compatible
// upstreams only accept enabled/disabled.
const thinking = sanitized.thinking;
if (isRecord(thinking)) {
const rawType = asTrimmedString(thinking.type).toLowerCase();
if (rawType === 'adaptive') {
const budgetTokens = toFiniteNumber(thinking.budget_tokens);
if (budgetTokens !== null && budgetTokens > 0) {
sanitized.thinking = {
...thinking,
type: 'enabled',
budget_tokens: Math.trunc(budgetTokens),
};
} else {
sanitized.thinking = { type: 'disabled' };
}
} else if (rawType && rawType !== 'enabled' && rawType !== 'disabled') {
sanitized.thinking = { type: 'disabled' };
}
}
return sanitized;
}
@@ -337,7 +363,24 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
return Array.from(normalized);
}
function preferredEndpointOrder(downstreamFormat: EndpointPreference): UpstreamEndpoint[] {
function preferredEndpointOrder(downstreamFormat: EndpointPreference, sitePlatform?: string): UpstreamEndpoint[] {
const platform = normalizePlatformName(sitePlatform);
if (platform === 'gemini') {
// Gemini upstream is routed through OpenAI-compatible chat endpoint.
return ['chat'];
}
if (platform === 'openai') {
return downstreamFormat === 'responses'
? ['responses', 'chat']
: ['chat', 'responses'];
}
if (platform === 'claude') {
return ['messages'];
}
if (downstreamFormat === 'responses') {
return ['responses', 'chat', 'messages'];
}
@@ -352,14 +395,15 @@ export async function resolveUpstreamEndpointCandidates(
modelName: string,
downstreamFormat: EndpointPreference,
): Promise<UpstreamEndpoint[]> {
if ((context.site.platform || '').toLowerCase() === 'anyrouter') {
const sitePlatform = normalizePlatformName(context.site.platform);
if (sitePlatform === 'anyrouter') {
// anyrouter deployments are effectively anthropic-protocol first.
return downstreamFormat === 'responses'
? ['responses', 'messages', 'chat']
: ['messages', 'chat'];
}
const preferred = preferredEndpointOrder(downstreamFormat);
const preferred = preferredEndpointOrder(downstreamFormat, context.site.platform);
try {
const catalog = await fetchModelPricingCatalog({
@@ -415,6 +459,8 @@ export function buildUpstreamEndpointRequest(input: {
modelName: string;
stream: boolean;
tokenValue: string;
sitePlatform?: string;
siteUrl?: string;
openaiBody: Record<string, unknown>;
downstreamFormat: EndpointPreference;
claudeOriginalBody?: Record<string, unknown>;
@@ -425,12 +471,49 @@ export function buildUpstreamEndpointRequest(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
} {
const sitePlatform = normalizePlatformName(input.sitePlatform);
const isClaudeUpstream = sitePlatform === 'claude';
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
const openAiCompatBase = /\/openai(?:\/|$)/.test(normalizedSiteUrl);
if (openAiCompatBase) {
return endpoint === 'responses'
? '/responses'
: '/chat/completions';
}
return endpoint === 'responses'
? '/v1beta/openai/responses'
: '/v1beta/openai/chat/completions';
};
const resolveEndpointPath = (endpoint: UpstreamEndpoint): string => {
if (sitePlatform === 'gemini') {
return resolveGeminiEndpointPath(endpoint);
}
if (sitePlatform === 'openai') {
if (endpoint === 'responses') return '/v1/responses';
return '/v1/chat/completions';
}
if (sitePlatform === 'claude') {
return '/v1/messages';
}
if (endpoint === 'messages') return '/v1/messages';
if (endpoint === 'responses') return '/v1/responses';
return '/v1/chat/completions';
};
const passthroughHeaders = extractSafePassthroughHeaders(input.downstreamHeaders);
const commonHeaders = {
const commonHeaders: Record<string, string> = {
...passthroughHeaders,
'Content-Type': 'application/json',
Authorization: `Bearer ${input.tokenValue}`,
};
if (!isClaudeUpstream) {
commonHeaders.Authorization = `Bearer ${input.tokenValue}`;
}
if (input.endpoint === 'messages') {
const claudeHeaders = input.downstreamFormat === 'claude'
@@ -460,7 +543,7 @@ export function buildUpstreamEndpointRequest(input: {
}, input.stream);
return {
path: '/v1/messages',
path: resolveEndpointPath('messages'),
headers,
body: sanitizedBody,
};
@@ -486,7 +569,7 @@ export function buildUpstreamEndpointRequest(input: {
}, input.stream);
return {
path: '/v1/responses',
path: resolveEndpointPath('responses'),
headers,
body,
};
@@ -494,7 +577,7 @@ export function buildUpstreamEndpointRequest(input: {
const headers = ensureStreamAcceptHeader(commonHeaders, input.stream);
return {
path: '/v1/chat/completions',
path: resolveEndpointPath('chat'),
headers,
body: {
...input.openaiBody,
+12 -1
View File
@@ -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');
});
});
+19
View File
@@ -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}`;
}
+4 -2
View File
@@ -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',
});
+1
View File
@@ -160,6 +160,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
name: siteName,
url: siteUrl,
platform,
proxyUrl: null,
status: 'active',
isPinned: false,
sortOrder: sites.length,
+8 -5
View File
@@ -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: {},
};
+10 -7
View File
@@ -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) {
+4 -1
View File
@@ -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> {
+28
View File
@@ -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');
+3 -1
View File
@@ -1,4 +1,5 @@
import type { RequestInit as UndiciRequestInit } from 'undici';
import { withSiteProxyRequestInit } from '../siteProxy.js';
export interface CheckinResult {
success: boolean;
@@ -183,7 +184,8 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
...options?.headers,
},
};
const res = await fetch(url, requestOptions);
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
const res = await fetch(url, proxiedRequestOptions);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
+55
View File
@@ -0,0 +1,55 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function resolveClaudeModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export class ClaudeAdapter extends BasePlatformAdapter {
readonly platformName = 'claude';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Official Anthropic API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(resolveClaudeModelsUrl(baseUrl), {
headers: {
'x-api-key': apiToken,
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
},
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function stripModelPrefix(name: string): string {
const trimmed = name.trim();
if (!trimmed) return '';
return trimmed.replace(/^models\//i, '');
}
function normalizeModelList(models: string[]): string[] {
const deduped = new Set<string>();
for (const model of models) {
const normalized = stripModelPrefix(model);
if (normalized) deduped.add(normalized);
}
return Array.from(deduped);
}
function isOpenAiCompatGeminiBase(baseUrl: string): boolean {
return /\/openai(?:\/|$)/i.test(baseUrl);
}
function resolveGeminiOpenAiModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/models$/i.test(normalized)) return normalized;
return `${normalized}/models`;
}
function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string {
const normalized = normalizeBaseUrl(baseUrl);
const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized)
? normalized
: `${normalized}/v1beta`;
const listBase = /\/models$/i.test(withVersion)
? withVersion
: `${withVersion}/models`;
const separator = listBase.includes('?') ? '&' : '?';
return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`;
}
export class GeminiAdapter extends BasePlatformAdapter {
readonly platformName = 'gemini';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return (
normalized.includes('generativelanguage.googleapis.com')
|| normalized.includes('googleapis.com/v1beta/openai')
|| normalized.includes('gemini.google.com')
);
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Gemini API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
const normalizedBase = normalizeBaseUrl(baseUrl);
if (isOpenAiCompatGeminiBase(normalizedBase)) {
try {
const res = await this.fetchJson<any>(resolveGeminiOpenAiModelsUrl(normalizedBase), {
headers: { Authorization: `Bearer ${apiToken}` },
});
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
} catch {}
}
try {
const res = await this.fetchJson<any>(resolveGeminiNativeModelsUrl(normalizedBase, apiToken));
const nativeModels = (res?.models || [])
.map((m: any) => String(m?.name || '').trim())
.filter(Boolean);
if (nativeModels.length > 0) return normalizeModelList(nativeModels);
} catch {}
if (!isOpenAiCompatGeminiBase(normalizedBase)) {
try {
const res = await this.fetchJson<any>(`${normalizedBase}/v1beta/openai/models`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
} catch {}
}
return [];
}
}
@@ -16,6 +16,12 @@ describe('getAdapter platform aliases', () => {
expect(getAdapter('unknown-platform')).toBeUndefined();
});
it('supports canonical openai/claude/gemini adapters', () => {
expect(getAdapter('openai')?.platformName).toBe('openai');
expect(getAdapter('claude')?.platformName).toBe('claude');
expect(getAdapter('gemini')?.platformName).toBe('gemini');
});
it('detects anyrouter URL before generic new-api adapter', async () => {
const adapter = await detectPlatform('https://anyrouter.top');
expect(adapter?.platformName).toBe('anyrouter');
@@ -25,4 +31,14 @@ describe('getAdapter platform aliases', () => {
const adapter = await detectPlatform('https://demo.donehub.example');
expect(adapter?.platformName).toBe('done-hub');
});
it('detects official openai/claude/gemini upstream URLs', async () => {
const openai = await detectPlatform('https://api.openai.com');
const claude = await detectPlatform('https://api.anthropic.com');
const gemini = await detectPlatform('https://generativelanguage.googleapis.com');
expect(openai?.platformName).toBe('openai');
expect(claude?.platformName).toBe('claude');
expect(gemini?.platformName).toBe('gemini');
});
});
+12
View File
@@ -6,9 +6,15 @@ import { VeloeraAdapter } from './veloera.js';
import { OneHubAdapter } from './oneHub.js';
import { DoneHubAdapter } from './doneHub.js';
import { Sub2ApiAdapter } from './sub2api.js';
import { OpenAiAdapter } from './openai.js';
import { ClaudeAdapter } from './claude.js';
import { GeminiAdapter } from './gemini.js';
const adapters: PlatformAdapter[] = [
// Specific forks before generic adapters for better auto-detection.
new OpenAiAdapter(),
new ClaudeAdapter(),
new GeminiAdapter(),
new AnyRouterAdapter(),
new DoneHubAdapter(),
new OneHubAdapter(),
@@ -38,6 +44,12 @@ const platformAliases: Record<string, string> = {
'one-hub': 'one-hub',
'done-hub': 'done-hub',
sub2api: 'sub2api',
// Official upstream APIs
openai: 'openai',
anthropic: 'claude',
claude: 'claude',
gemini: 'gemini',
google: 'gemini',
};
function normalizePlatform(platform: string): string {
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { OpenAiAdapter } from './openai.js';
import { ClaudeAdapter } from './claude.js';
import { GeminiAdapter } from './gemini.js';
interface RequestSnapshot {
method: string;
url: string;
headers: IncomingMessage['headers'];
}
describe('official llm upstream adapters', () => {
let server: ReturnType<typeof createServer>;
let baseUrl: string;
let requests: RequestSnapshot[] = [];
beforeEach(async () => {
requests = [];
server = createServer((req: IncomingMessage, res: ServerResponse) => {
requests.push({
method: req.method || 'GET',
url: req.url || '/',
headers: req.headers,
});
if (req.url === '/openai/v1/models') {
if (req.headers.authorization !== 'Bearer sk-openai') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: 'gpt-4.1' }, { id: 'gpt-4o-mini' }],
}));
return;
}
if (req.url === '/claude/v1/models') {
if (req.headers['x-api-key'] !== 'sk-claude' || req.headers['anthropic-version'] !== '2023-06-01') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: [{ id: 'claude-sonnet-4-5-20250929' }, { id: 'claude-haiku-4-5-20251001' }],
}));
return;
}
if (req.url?.startsWith('/gemini/v1beta/models')) {
const url = new URL(req.url, 'http://127.0.0.1');
if (url.searchParams.get('key') !== 'gemini-key') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
models: [
{ name: 'models/gemini-2.5-flash' },
{ name: 'models/gemini-2.5-pro' },
],
}));
return;
}
if (req.url === '/gemini-openai/v1beta/openai/models') {
if (req.headers.authorization !== 'Bearer gemini-key') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: 'gemini-2.5-flash' }],
}));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const addr = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => (err ? reject(err) : resolve()));
});
});
it('fetches models from openai upstream', async () => {
const adapter = new OpenAiAdapter();
const models = await adapter.getModels(`${baseUrl}/openai`, 'sk-openai');
expect(models).toEqual(['gpt-4.1', 'gpt-4o-mini']);
});
it('fetches models from claude upstream with anthropic headers', async () => {
const adapter = new ClaudeAdapter();
const models = await adapter.getModels(`${baseUrl}/claude`, 'sk-claude');
expect(models).toEqual(['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001']);
});
it('fetches models from gemini native endpoint and normalizes model names', async () => {
const adapter = new GeminiAdapter();
const models = await adapter.getModels(`${baseUrl}/gemini/v1beta`, 'gemini-key');
expect(models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']);
});
it('fetches models from gemini openai-compatible endpoint when configured', async () => {
const adapter = new GeminiAdapter();
const models = await adapter.getModels(`${baseUrl}/gemini-openai/v1beta/openai`, 'gemini-key');
expect(models).toEqual(['gemini-2.5-flash']);
});
});
+1 -1
View File
@@ -322,7 +322,7 @@ describe('NewApiAdapter', () => {
expect(token).toBe('api-key-from-token-list');
});
it('detects cookie session values as session tokens for anyrouter-like deployments', async () => {
it('detects cookie session values as session cookies for anyrouter-like deployments', async () => {
const adapter = new NewApiAdapter();
const result = await adapter.verifyToken(baseUrl, COOKIE_SESSION_TOKEN);
+3 -1
View File
@@ -1,6 +1,7 @@
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js';
import type { RequestInit as UndiciRequestInit } from 'undici';
import { createContext, runInContext } from 'node:vm';
import { withSiteProxyRequestInit } from '../siteProxy.js';
export class NewApiAdapter extends BasePlatformAdapter {
readonly platformName: string = 'new-api';
@@ -453,7 +454,8 @@ export class NewApiAdapter extends BasePlatformAdapter {
body: options?.body ?? undefined,
headers,
};
const res = await fetch(url, requestOptions);
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
const res = await fetch(url, proxiedRequestOptions);
const text = await res.text();
const parsed = this.parseJsonSafe<T>(text);
if (parsed) return parsed;
+50
View File
@@ -0,0 +1,50 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function resolveOpenAiModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export class OpenAiAdapter extends BasePlatformAdapter {
readonly platformName = 'openai';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return normalized.includes('api.openai.com');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Official OpenAI API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(resolveOpenAiModelsUrl(baseUrl), {
headers: { Authorization: `Bearer ${apiToken}` },
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
}
}
@@ -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}`);
+71
View File
@@ -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);
});
});
+202
View File
@@ -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');
});
});
+309 -47
View File
@@ -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)}%)`,
};
});