forked from boos/BoosAPI
feat(tests): add comprehensive tests for TokenRouter and related components
- Implement tests for TokenRouter downstream policy, covering allowedRouteIds and site weight multipliers. - Add tests for TokenRouter patterns and model mapping, including regex pattern matching and model mapping precedence. - Create tests for BrandIcon component to verify brand detection logic. - Develop tests for Accounts page, focusing on rebind panel behavior and refresh action on error. - Introduce fuzzy search utility tests to validate substring and subsequence matching. - Implement navigation focus helpers tests for building and parsing focus paths. - Add progressive render tests to ensure correct initial and next visible counts. - Create route model candidates index tests to validate route candidate indexing and matching logic.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user