forked from boos/BoosAPI
fix: address gemini native and route coverage regressions
This commit is contained in:
@@ -0,0 +1,203 @@
|
|||||||
|
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 getApiTokensMock = vi.fn();
|
||||||
|
const getApiTokenMock = vi.fn();
|
||||||
|
const createApiTokenMock = vi.fn();
|
||||||
|
const getUserGroupsMock = vi.fn();
|
||||||
|
const deleteApiTokenMock = vi.fn();
|
||||||
|
const getModelsMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../services/platforms/index.js', () => ({
|
||||||
|
getAdapter: () => ({
|
||||||
|
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
|
||||||
|
getApiToken: (...args: unknown[]) => getApiTokenMock(...args),
|
||||||
|
createApiToken: (...args: unknown[]) => createApiTokenMock(...args),
|
||||||
|
getUserGroups: (...args: unknown[]) => getUserGroupsMock(...args),
|
||||||
|
deleteApiToken: (...args: unknown[]) => deleteApiTokenMock(...args),
|
||||||
|
getModels: (...args: unknown[]) => getModelsMock(...args),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('account token coverage refresh', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
let seedId = 0;
|
||||||
|
|
||||||
|
const nextSeed = () => {
|
||||||
|
seedId += 1;
|
||||||
|
return seedId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedAccount = async (modelName: string) => {
|
||||||
|
const id = nextSeed();
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: `site-${id}`,
|
||||||
|
url: `https://site-${id}.example.com`,
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: `user-${id}`,
|
||||||
|
accessToken: `acc-token-${id}`,
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.modelAvailability).values({
|
||||||
|
accountId: account.id,
|
||||||
|
modelName,
|
||||||
|
available: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
return { site, account };
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTokenCandidates = async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/models/token-candidates',
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
return response.json() as {
|
||||||
|
models: Record<string, Array<{ tokenId: number; accountId: number }>>;
|
||||||
|
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const accountTokenRoutesModule = await import('./accountTokens.js');
|
||||||
|
const statsRoutesModule = await import('./stats.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(accountTokenRoutesModule.accountTokensRoutes);
|
||||||
|
await app.register(statsRoutesModule.statsRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
getApiTokensMock.mockReset();
|
||||||
|
getApiTokenMock.mockReset();
|
||||||
|
createApiTokenMock.mockReset();
|
||||||
|
getUserGroupsMock.mockReset();
|
||||||
|
deleteApiTokenMock.mockReset();
|
||||||
|
getModelsMock.mockReset();
|
||||||
|
seedId = 0;
|
||||||
|
|
||||||
|
await db.delete(schema.proxyLogs).run();
|
||||||
|
await db.delete(schema.checkinLogs).run();
|
||||||
|
await db.delete(schema.routeChannels).run();
|
||||||
|
await db.delete(schema.tokenRoutes).run();
|
||||||
|
await db.delete(schema.tokenModelAvailability).run();
|
||||||
|
await db.delete(schema.modelAvailability).run();
|
||||||
|
await db.delete(schema.accountTokens).run();
|
||||||
|
await db.delete(schema.accounts).run();
|
||||||
|
await db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes token coverage after manually adding an account token', async () => {
|
||||||
|
const modelName = 'gpt-4o-mini';
|
||||||
|
const { account } = await seedAccount(modelName);
|
||||||
|
getModelsMock.mockImplementation(async (_url: string, credential: string) => {
|
||||||
|
if (credential === account.accessToken || credential === 'sk-manual-token') {
|
||||||
|
return [modelName];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/account-tokens',
|
||||||
|
payload: {
|
||||||
|
accountId: account.id,
|
||||||
|
name: 'manual-token',
|
||||||
|
token: 'sk-manual-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const token = await db.select()
|
||||||
|
.from(schema.accountTokens)
|
||||||
|
.where(eq(schema.accountTokens.accountId, account.id))
|
||||||
|
.get();
|
||||||
|
expect(token).toBeDefined();
|
||||||
|
|
||||||
|
const tokenAvailability = await db.select()
|
||||||
|
.from(schema.tokenModelAvailability)
|
||||||
|
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
|
||||||
|
.all();
|
||||||
|
expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName);
|
||||||
|
|
||||||
|
const candidates = await readTokenCandidates();
|
||||||
|
expect(candidates.modelsWithoutToken[modelName]).toBeUndefined();
|
||||||
|
expect(candidates.models[modelName]).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: token!.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes token coverage after syncing account tokens from upstream', async () => {
|
||||||
|
const modelName = 'claude-sonnet-4-5-20250929';
|
||||||
|
const { account } = await seedAccount(modelName);
|
||||||
|
getApiTokensMock.mockResolvedValue([
|
||||||
|
{ name: 'default', key: 'sk-synced-token', enabled: true },
|
||||||
|
]);
|
||||||
|
getModelsMock.mockImplementation(async (_url: string, credential: string) => {
|
||||||
|
if (credential === account.accessToken || credential === 'sk-synced-token') {
|
||||||
|
return [modelName];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/account-tokens/sync/${account.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const token = await db.select()
|
||||||
|
.from(schema.accountTokens)
|
||||||
|
.where(eq(schema.accountTokens.accountId, account.id))
|
||||||
|
.get();
|
||||||
|
expect(token).toBeDefined();
|
||||||
|
|
||||||
|
const tokenAvailability = await db.select()
|
||||||
|
.from(schema.tokenModelAvailability)
|
||||||
|
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
|
||||||
|
.all();
|
||||||
|
expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName);
|
||||||
|
|
||||||
|
const candidates = await readTokenCandidates();
|
||||||
|
expect(candidates.modelsWithoutToken[modelName]).toBeUndefined();
|
||||||
|
expect(candidates.models[modelName]).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: token!.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
|
import { rebuildTokenRoutesFromAvailability, refreshModelsForAccount } from '../../services/modelService.js';
|
||||||
|
|
||||||
type AccountWithSiteRow = {
|
type AccountWithSiteRow = {
|
||||||
accounts: typeof schema.accounts.$inferSelect;
|
accounts: typeof schema.accounts.$inferSelect;
|
||||||
@@ -337,6 +338,12 @@ async function executeSyncAllAccountTokens() {
|
|||||||
results.push(...batchResults);
|
results.push(...batchResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const coverageRefresh = await refreshCoverageForAccounts(
|
||||||
|
results
|
||||||
|
.filter((item) => item.status === 'synced')
|
||||||
|
.map((item) => item.accountId),
|
||||||
|
);
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
total: results.length,
|
total: results.length,
|
||||||
synced: results.filter((item) => item.status === 'synced').length,
|
synced: results.filter((item) => item.status === 'synced').length,
|
||||||
@@ -346,7 +353,23 @@ async function executeSyncAllAccountTokens() {
|
|||||||
updated: results.reduce((acc, item) => acc + item.updated, 0),
|
updated: results.reduce((acc, item) => acc + item.updated, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { summary, results };
|
return { summary, results, coverageRefresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCoverageForAccounts(accountIds: number[]) {
|
||||||
|
const uniqueAccountIds = Array.from(new Set(
|
||||||
|
accountIds.filter((id) => Number.isFinite(id) && id > 0),
|
||||||
|
));
|
||||||
|
|
||||||
|
if (uniqueAccountIds.length === 0) {
|
||||||
|
return { refresh: [], rebuild: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = await Promise.all(
|
||||||
|
uniqueAccountIds.map(async (accountId) => refreshModelsForAccount(accountId)),
|
||||||
|
);
|
||||||
|
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||||
|
return { refresh, rebuild };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function accountTokensRoutes(app: FastifyInstance) {
|
export async function accountTokensRoutes(app: FastifyInstance) {
|
||||||
@@ -416,8 +439,8 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
|||||||
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
|
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
|
||||||
await setDefaultToken(created.id);
|
await setDefaultToken(created.id);
|
||||||
}
|
}
|
||||||
|
const coverageRefresh = await refreshCoverageForAccounts([body.accountId]);
|
||||||
return { success: true, token: created };
|
return { success: true, token: created, coverageRefresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = row.accounts;
|
const account = row.accounts;
|
||||||
@@ -496,6 +519,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
|||||||
if (syncResult.status === 'skipped') {
|
if (syncResult.status === 'skipped') {
|
||||||
return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' });
|
return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' });
|
||||||
}
|
}
|
||||||
|
const coverageRefresh = await refreshCoverageForAccounts([account.id]);
|
||||||
|
|
||||||
const preferred = await db.select().from(schema.accountTokens)
|
const preferred = await db.select().from(schema.accountTokens)
|
||||||
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true)))
|
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true)))
|
||||||
@@ -509,6 +533,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
|||||||
success: true,
|
success: true,
|
||||||
createdViaUpstream: true,
|
createdViaUpstream: true,
|
||||||
...syncResult,
|
...syncResult,
|
||||||
|
coverageRefresh,
|
||||||
token,
|
token,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -823,7 +848,8 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
|||||||
if (result.status === 'failed') {
|
if (result.status === 'failed') {
|
||||||
return reply.code(502).send({ success: false, message: result.message || '同步失败' });
|
return reply.code(502).send({ success: false, message: result.message || '同步失败' });
|
||||||
}
|
}
|
||||||
return { success: true, ...result };
|
const coverageRefresh = await refreshCoverageForAccounts([accountId]);
|
||||||
|
return { success: true, ...result, coverageRefresh };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => {
|
app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => {
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
|||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const selectChannelMock = vi.fn();
|
const selectChannelMock = vi.fn();
|
||||||
const selectNextChannelMock = vi.fn();
|
const selectNextChannelMock = vi.fn();
|
||||||
|
const recordSuccessMock = vi.fn();
|
||||||
const recordFailureMock = vi.fn();
|
const recordFailureMock = vi.fn();
|
||||||
|
const explainSelectionMock = vi.fn();
|
||||||
const invalidateTokenRouterCacheMock = vi.fn();
|
const invalidateTokenRouterCacheMock = vi.fn();
|
||||||
const authorizeDownstreamTokenMock = vi.fn();
|
const authorizeDownstreamTokenMock = vi.fn();
|
||||||
const consumeManagedKeyRequestMock = vi.fn();
|
const consumeManagedKeyRequestMock = vi.fn();
|
||||||
|
const isModelAllowedByPolicyOrAllowedRoutesMock = vi.fn();
|
||||||
|
const dbInsertValuesMock = vi.fn((_values?: unknown) => ({
|
||||||
|
run: () => undefined,
|
||||||
|
}));
|
||||||
|
const dbInsertMock = vi.fn((_table?: unknown) => ({
|
||||||
|
values: (values: unknown) => dbInsertValuesMock(values),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', () => ({
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
@@ -19,8 +28,9 @@ vi.mock('../../services/tokenRouter.js', () => ({
|
|||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||||
|
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||||
explainSelection: vi.fn(async () => ({ selectedChannelId: 11 })),
|
explainSelection: (...args: unknown[]) => explainSelectionMock(...args),
|
||||||
},
|
},
|
||||||
invalidateTokenRouterCache: (...args: unknown[]) => invalidateTokenRouterCacheMock(...args),
|
invalidateTokenRouterCache: (...args: unknown[]) => invalidateTokenRouterCacheMock(...args),
|
||||||
}));
|
}));
|
||||||
@@ -28,6 +38,16 @@ vi.mock('../../services/tokenRouter.js', () => ({
|
|||||||
vi.mock('../../services/downstreamApiKeyService.js', () => ({
|
vi.mock('../../services/downstreamApiKeyService.js', () => ({
|
||||||
authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args),
|
authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args),
|
||||||
consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args),
|
consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args),
|
||||||
|
isModelAllowedByPolicyOrAllowedRoutes: (...args: unknown[]) => isModelAllowedByPolicyOrAllowedRoutesMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../db/index.js', () => ({
|
||||||
|
db: {
|
||||||
|
insert: (arg: unknown) => dbInsertMock(arg),
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
proxyLogs: {},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function parseSsePayloads(body: string): Array<Record<string, unknown>> {
|
function parseSsePayloads(body: string): Array<Record<string, unknown>> {
|
||||||
@@ -82,9 +102,14 @@ describe('gemini native proxy routes', () => {
|
|||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
selectChannelMock.mockReset();
|
selectChannelMock.mockReset();
|
||||||
selectNextChannelMock.mockReset();
|
selectNextChannelMock.mockReset();
|
||||||
|
recordSuccessMock.mockReset();
|
||||||
recordFailureMock.mockReset();
|
recordFailureMock.mockReset();
|
||||||
|
explainSelectionMock.mockReset();
|
||||||
authorizeDownstreamTokenMock.mockReset();
|
authorizeDownstreamTokenMock.mockReset();
|
||||||
consumeManagedKeyRequestMock.mockReset();
|
consumeManagedKeyRequestMock.mockReset();
|
||||||
|
isModelAllowedByPolicyOrAllowedRoutesMock.mockReset();
|
||||||
|
dbInsertMock.mockClear();
|
||||||
|
dbInsertValuesMock.mockClear();
|
||||||
|
|
||||||
authorizeDownstreamTokenMock.mockResolvedValue({
|
authorizeDownstreamTokenMock.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -102,7 +127,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
actualModel: 'gemini-2.5-flash',
|
actualModel: 'gemini-2.5-flash',
|
||||||
});
|
});
|
||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
|
recordSuccessMock.mockResolvedValue(undefined);
|
||||||
recordFailureMock.mockResolvedValue(undefined);
|
recordFailureMock.mockResolvedValue(undefined);
|
||||||
|
explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 });
|
||||||
|
isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -241,6 +269,108 @@ describe('gemini native proxy routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters Gemini native model list by downstream managed-key policy', async () => {
|
||||||
|
authorizeDownstreamTokenMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
source: 'managed',
|
||||||
|
token: 'sk-managed-gemini',
|
||||||
|
key: { id: 91 },
|
||||||
|
policy: { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} },
|
||||||
|
});
|
||||||
|
isModelAllowedByPolicyOrAllowedRoutesMock.mockImplementation(async (modelName: string) => modelName === 'gemini-2.5-flash');
|
||||||
|
explainSelectionMock.mockImplementation(async (modelName: string) => (
|
||||||
|
modelName === 'gemini-2.5-flash'
|
||||||
|
? { selectedChannelId: 11 }
|
||||||
|
: { selectedChannelId: undefined }
|
||||||
|
));
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
models: [
|
||||||
|
{ name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
|
||||||
|
{ name: 'models/gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' },
|
||||||
|
],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1beta/models',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer sk-managed-gemini',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-flash',
|
||||||
|
displayName: 'Gemini 2.5 Flash',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(isModelAllowedByPolicyOrAllowedRoutesMock).toHaveBeenCalledWith('gemini-2.5-flash', { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} });
|
||||||
|
expect(isModelAllowedByPolicyOrAllowedRoutesMock).toHaveBeenCalledWith('gemini-2.0-flash', { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a proxy log row for successful native generateContent requests', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'hello from gemini' }],
|
||||||
|
role: 'model',
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 10,
|
||||||
|
candidatesTokenCount: 5,
|
||||||
|
totalTokenCount: 15,
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1beta/models/gemini-2.5-flash:generateContent',
|
||||||
|
headers: {
|
||||||
|
'x-goog-api-key': 'sk-managed-gemini',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(recordSuccessMock).toHaveBeenCalledWith(11, expect.any(Number), 0);
|
||||||
|
expect(dbInsertMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dbInsertValuesMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
routeId: 22,
|
||||||
|
channelId: 11,
|
||||||
|
accountId: 33,
|
||||||
|
modelRequested: 'gemini-2.5-flash',
|
||||||
|
modelActual: 'gemini-2.5-flash',
|
||||||
|
status: 'success',
|
||||||
|
httpStatus: 200,
|
||||||
|
retryCount: 0,
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:generateContent] [upstream:/v1beta/models/gemini-2.5-flash:generateContent]',
|
||||||
|
createdAt: expect.any(String),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('forwards explicit gemini version paths through transformer-owned parsing helpers', async () => {
|
it('forwards explicit gemini version paths through transformer-owned parsing helpers', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
candidates: [
|
candidates: [
|
||||||
@@ -842,4 +972,67 @@ describe('gemini native proxy routes', () => {
|
|||||||
expect(recordFailureMock).toHaveBeenCalledWith(11);
|
expect(recordFailureMock).toHaveBeenCalledWith(11);
|
||||||
expect(response.body).toContain('hello from fallback sse');
|
expect(response.body).toContain('hello from fallback sse');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes failed and successful proxy log rows for Gemini-native stream retries', async () => {
|
||||||
|
selectNextChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 12, routeId: 22 },
|
||||||
|
site: { id: 45, name: 'gemini-site-2', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' },
|
||||||
|
account: { id: 34, username: 'demo-user-2' },
|
||||||
|
tokenName: 'fallback',
|
||||||
|
tokenValue: 'gemini-key-2',
|
||||||
|
actualModel: 'gemini-2.5-flash',
|
||||||
|
});
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('data: {"responseId":"resp-fallback","candidates":[{"content":{"role":"model","parts":[{"text":"hello from fallback sse"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17}}\r\n\r\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: { message: 'upstream unavailable' },
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(upstreamBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse',
|
||||||
|
headers: {
|
||||||
|
'x-goog-api-key': 'sk-managed-gemini',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(dbInsertMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dbInsertValuesMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
channelId: 11,
|
||||||
|
status: 'failed',
|
||||||
|
httpStatus: 500,
|
||||||
|
retryCount: 0,
|
||||||
|
errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] [upstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] {\"error\":{\"message\":\"upstream unavailable\"}}',
|
||||||
|
}));
|
||||||
|
expect(dbInsertValuesMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
channelId: 12,
|
||||||
|
status: 'success',
|
||||||
|
httpStatus: 200,
|
||||||
|
retryCount: 1,
|
||||||
|
promptTokens: 11,
|
||||||
|
completionTokens: 6,
|
||||||
|
totalTokens: 17,
|
||||||
|
errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] [upstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent]',
|
||||||
|
}));
|
||||||
|
expect(recordSuccessMock).toHaveBeenCalledWith(12, expect.any(Number), 0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
|
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||||
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
import {
|
import {
|
||||||
geminiGenerateContentTransformer,
|
geminiGenerateContentTransformer,
|
||||||
} from '../../transformers/gemini/generate-content/index.js';
|
} from '../../transformers/gemini/generate-content/index.js';
|
||||||
@@ -14,6 +19,11 @@ const GEMINI_MODEL_PROBES = [
|
|||||||
'gemini-1.5-flash',
|
'gemini-1.5-flash',
|
||||||
'gemini-pro',
|
'gemini-pro',
|
||||||
];
|
];
|
||||||
|
const EMPTY_PROXY_USAGE = {
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
async function selectGeminiChannel(request: FastifyRequest) {
|
async function selectGeminiChannel(request: FastifyRequest) {
|
||||||
const policy = getDownstreamRoutingPolicy(request);
|
const policy = getDownstreamRoutingPolicy(request);
|
||||||
@@ -33,6 +43,106 @@ async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChan
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDownstreamPath(request: FastifyRequest): string {
|
||||||
|
const rawUrl = request.raw.url || request.url || '';
|
||||||
|
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
|
||||||
|
return withoutQuery || '/v1beta/models';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string {
|
||||||
|
const normalizedVersion = apiVersion.replace(/^\/+/, '');
|
||||||
|
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
||||||
|
return `/${normalizedVersion}/${normalizedAction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean {
|
||||||
|
const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : [];
|
||||||
|
const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : [];
|
||||||
|
return supportedModels.length > 0 || allowedRouteIds.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiListedModelName(item: unknown): string {
|
||||||
|
if (!item || typeof item !== 'object') return '';
|
||||||
|
const rawName = typeof (item as { name?: unknown }).name === 'string'
|
||||||
|
? (item as { name: string }).name.trim()
|
||||||
|
: '';
|
||||||
|
if (!rawName) return '';
|
||||||
|
return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterGeminiListedModelsForPolicy(
|
||||||
|
payload: unknown,
|
||||||
|
request: FastifyRequest,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = getDownstreamRoutingPolicy(request);
|
||||||
|
if (!hasDownstreamModelRestrictions(policy)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredModels: unknown[] = [];
|
||||||
|
for (const item of (payload as { models: unknown[] }).models) {
|
||||||
|
const modelName = extractGeminiListedModelName(item);
|
||||||
|
if (!modelName) continue;
|
||||||
|
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue;
|
||||||
|
const decision = await tokenRouter.explainSelection?.(modelName, [], policy);
|
||||||
|
if (decision && typeof decision.selectedChannelId !== 'number') continue;
|
||||||
|
filteredModels.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(payload as Record<string, unknown>),
|
||||||
|
models: filteredModels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logProxy(
|
||||||
|
selected: any,
|
||||||
|
modelRequested: string,
|
||||||
|
status: string,
|
||||||
|
httpStatus: number,
|
||||||
|
latencyMs: number,
|
||||||
|
errorMessage: string | null,
|
||||||
|
retryCount: number,
|
||||||
|
downstreamPath: string,
|
||||||
|
upstreamPath: string | null,
|
||||||
|
promptTokens = 0,
|
||||||
|
completionTokens = 0,
|
||||||
|
totalTokens = 0,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
|
downstreamPath,
|
||||||
|
upstreamPath,
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
|
await db.insert(schema.proxyLogs).values({
|
||||||
|
routeId: selected.channel.routeId,
|
||||||
|
channelId: selected.channel.id,
|
||||||
|
accountId: selected.account.id,
|
||||||
|
modelRequested,
|
||||||
|
modelActual: selected.actualModel || modelRequested,
|
||||||
|
status,
|
||||||
|
httpStatus,
|
||||||
|
latencyMs,
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
totalTokens,
|
||||||
|
estimatedCost: 0,
|
||||||
|
billingDetails: null,
|
||||||
|
errorMessage: normalizedErrorMessage,
|
||||||
|
retryCount,
|
||||||
|
createdAt,
|
||||||
|
}).run();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[proxy/gemini] failed to write proxy log', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function geminiProxyRoute(app: FastifyInstance) {
|
export async function geminiProxyRoute(app: FastifyInstance) {
|
||||||
const listModels = async (request: FastifyRequest, reply: FastifyReply) => {
|
const listModels = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion(
|
const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion(
|
||||||
@@ -72,7 +182,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return reply.code(upstream.status).send(JSON.parse(text));
|
const parsed = JSON.parse(text);
|
||||||
|
const filtered = await filterGeminiListedModelsForPolicy(parsed, request);
|
||||||
|
return reply.code(upstream.status).send(filtered);
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
||||||
}
|
}
|
||||||
@@ -107,6 +219,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const policy = getDownstreamRoutingPolicy(request);
|
const policy = getDownstreamRoutingPolicy(request);
|
||||||
|
const downstreamPath = resolveDownstreamPath(request);
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
let lastStatus = 503;
|
let lastStatus = 503;
|
||||||
@@ -132,7 +245,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
/^models\/[^:]+/,
|
/^models\/[^:]+/,
|
||||||
`models/${selected.actualModel || requestedModel}`,
|
`models/${selected.actualModel || requestedModel}`,
|
||||||
);
|
);
|
||||||
|
const upstreamPath = resolveUpstreamPath(apiVersion, actualModelAction);
|
||||||
const query = new URLSearchParams(request.query as Record<string, string>).toString();
|
const query = new URLSearchParams(request.query as Record<string, string>).toString();
|
||||||
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(
|
const upstream = await fetch(
|
||||||
geminiGenerateContentTransformer.resolveActionUrl(
|
geminiGenerateContentTransformer.resolveActionUrl(
|
||||||
@@ -156,6 +271,17 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
lastContentType = contentType;
|
lastContentType = contentType;
|
||||||
lastText = await upstream.text();
|
lastText = await upstream.text();
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||||
|
await logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
lastStatus,
|
||||||
|
Date.now() - startTime,
|
||||||
|
lastText,
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
|
upstreamPath,
|
||||||
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -174,6 +300,19 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
|
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
|
||||||
const reader = upstream.body?.getReader();
|
const reader = upstream.body?.getReader();
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||||
|
await logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'success',
|
||||||
|
upstream.status,
|
||||||
|
latency,
|
||||||
|
null,
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
|
upstreamPath,
|
||||||
|
);
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,21 +348,70 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
}
|
}
|
||||||
|
const parsedUsage = parseProxyUsage(aggregateState);
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||||
|
await logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'success',
|
||||||
|
upstream.status,
|
||||||
|
latency,
|
||||||
|
null,
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
|
upstreamPath,
|
||||||
|
parsedUsage.promptTokens,
|
||||||
|
parsedUsage.completionTokens,
|
||||||
|
parsedUsage.totalTokens,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
|
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||||
|
let parsedUsage = EMPTY_PROXY_USAGE;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
const responsePayload = geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
||||||
return reply.code(upstream.status).send(
|
|
||||||
geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
|
||||||
aggregateState,
|
aggregateState,
|
||||||
parsed,
|
parsed,
|
||||||
isStreamAction,
|
isStreamAction,
|
||||||
),
|
);
|
||||||
|
parsedUsage = parseProxyUsage(aggregateState);
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||||
|
await logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'success',
|
||||||
|
upstream.status,
|
||||||
|
latency,
|
||||||
|
null,
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
|
upstreamPath,
|
||||||
|
parsedUsage.promptTokens,
|
||||||
|
parsedUsage.completionTokens,
|
||||||
|
parsedUsage.totalTokens,
|
||||||
|
);
|
||||||
|
return reply.code(upstream.status).send(
|
||||||
|
responsePayload,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||||
|
await logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'success',
|
||||||
|
upstream.status,
|
||||||
|
latency,
|
||||||
|
null,
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
|
upstreamPath,
|
||||||
|
);
|
||||||
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -236,6 +424,17 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||||
|
await logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
0,
|
||||||
|
Date.now() - startTime,
|
||||||
|
error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
|
upstreamPath,
|
||||||
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -154,4 +154,31 @@ describe('TokenRouter patterns and model mapping', () => {
|
|||||||
expect(decision.actualModel).toBe('claude-opus-4-5');
|
expect(decision.actualModel).toBe('claude-opus-4-5');
|
||||||
expect(exposedModels).toContain('claude-opus-4-6');
|
expect(exposedModels).toContain('claude-opus-4-6');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers an exact route over a colliding group display-name alias', async () => {
|
||||||
|
await createRouteWithSingleChannel(
|
||||||
|
're:^claude-(opus|sonnet)-4-5$',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
displayName: 'claude-opus-4-6',
|
||||||
|
sourceModel: 'claude-opus-4-5',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const exact = await createRouteWithSingleChannel(
|
||||||
|
'claude-opus-4-6',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
sourceModel: 'claude-opus-4-6',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
const selected = await router.selectChannel('claude-opus-4-6');
|
||||||
|
const decision = await router.explainSelection('claude-opus-4-6');
|
||||||
|
|
||||||
|
expect(selected).toBeTruthy();
|
||||||
|
expect(selected?.channel.id).toBe(exact.channel.id);
|
||||||
|
expect(selected?.actualModel).toBe('claude-opus-4-6');
|
||||||
|
expect(decision.actualModel).toBe('claude-opus-4-6');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -271,6 +271,13 @@ export function matchesModelPattern(model: string, pattern: string): boolean {
|
|||||||
return minimatch(model, normalizedPattern);
|
return minimatch(model, normalizedPattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExactRouteModelPattern(pattern: string): boolean {
|
||||||
|
const normalizedPattern = (pattern || '').trim();
|
||||||
|
if (!normalizedPattern) return false;
|
||||||
|
if (isRegexModelPattern(normalizedPattern)) return false;
|
||||||
|
return !/[\*\?\[]/.test(normalizedPattern);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
|
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
|
||||||
return (displayName || '').trim();
|
return (displayName || '').trim();
|
||||||
}
|
}
|
||||||
@@ -993,10 +1000,12 @@ export class TokenRouter {
|
|||||||
routes = routes.filter((route) => allowSet.has(route.id));
|
routes = routes.filter((route) => allowSet.has(route.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find matching route by model pattern or display alias.
|
const matchedRoute = routes.find((route) => (
|
||||||
const matchedRoute = routes.find((r) => {
|
isExactRouteModelPattern(route.modelPattern)
|
||||||
return matchesRouteRequestModel(model, r);
|
&& (route.modelPattern || '').trim() === model
|
||||||
});
|
))
|
||||||
|
|| routes.find((route) => isRouteDisplayNameMatch(model, route.displayName))
|
||||||
|
|| routes.find((route) => matchesModelPattern(model, route.modelPattern));
|
||||||
|
|
||||||
if (!matchedRoute) return null;
|
if (!matchedRoute) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
buildApiPayload,
|
buildApiPayload,
|
||||||
buildEmbeddingsRequestEnvelope,
|
buildEmbeddingsRequestEnvelope,
|
||||||
buildFileUploadRequestEnvelope,
|
buildFileUploadRequestEnvelope,
|
||||||
|
buildGeminiNativeConversationProxyEnvelope,
|
||||||
buildImagesEditRequestEnvelope,
|
buildImagesEditRequestEnvelope,
|
||||||
buildImagesGenerationsRequestEnvelope,
|
buildImagesGenerationsRequestEnvelope,
|
||||||
buildRawProxyRequestEnvelope,
|
buildRawProxyRequestEnvelope,
|
||||||
@@ -1027,30 +1028,6 @@ export default function ModelTester() {
|
|||||||
};
|
};
|
||||||
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.model, inputs.stream, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
|
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.model, inputs.stream, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
|
||||||
|
|
||||||
const buildGeminiBodyFromMessages = useCallback((baseMessages: ChatMessage[]) => {
|
|
||||||
const effectiveMessages = buildConversationMessagesWithSystem(baseMessages);
|
|
||||||
const systemContents = effectiveMessages
|
|
||||||
.filter((message) => message.role === 'system' || message.role === 'developer')
|
|
||||||
.map((message) => message.content.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const contents = effectiveMessages
|
|
||||||
.filter((message) => message.role !== 'system' && message.role !== 'developer')
|
|
||||||
.map((message) => ({
|
|
||||||
role: message.role === 'assistant' ? 'model' : 'user',
|
|
||||||
parts: [{ text: message.content }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(systemContents.length > 0 ? { systemInstruction: { parts: [{ text: systemContents.join('\n\n') }] } } : {}),
|
|
||||||
contents,
|
|
||||||
generationConfig: {
|
|
||||||
...(parameterEnabled.temperature ? { temperature: inputs.temperature } : {}),
|
|
||||||
...(parameterEnabled.top_p ? { topP: inputs.top_p } : {}),
|
|
||||||
...(parameterEnabled.max_tokens ? { maxOutputTokens: inputs.max_tokens } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
|
|
||||||
|
|
||||||
const buildConversationProxyEnvelope = useCallback((baseMessages: ChatMessage[]): ProxyTestEnvelope => {
|
const buildConversationProxyEnvelope = useCallback((baseMessages: ChatMessage[]): ProxyTestEnvelope => {
|
||||||
const normalizedMessages = buildConversationMessagesWithSystem(baseMessages);
|
const normalizedMessages = buildConversationMessagesWithSystem(baseMessages);
|
||||||
|
|
||||||
@@ -1099,15 +1076,7 @@ export default function ModelTester() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (inputs.protocol === 'gemini') {
|
if (inputs.protocol === 'gemini') {
|
||||||
return {
|
return buildGeminiNativeConversationProxyEnvelope(baseMessages, inputs, parameterEnabled);
|
||||||
method: 'POST',
|
|
||||||
path: `/gemini/v1beta/models/${encodeURIComponent(inputs.model)}:generateContent${inputs.stream ? '?alt=sse' : ''}`,
|
|
||||||
requestKind: 'json',
|
|
||||||
stream: inputs.stream,
|
|
||||||
jobMode: false,
|
|
||||||
rawMode: false,
|
|
||||||
jsonBody: buildGeminiBodyFromMessages(baseMessages),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAiEnvelope = buildApiPayload(normalizedMessages, { ...inputs, protocol: 'openai' }, parameterEnabled);
|
const openAiEnvelope = buildApiPayload(normalizedMessages, { ...inputs, protocol: 'openai' }, parameterEnabled);
|
||||||
@@ -1124,7 +1093,7 @@ export default function ModelTester() {
|
|||||||
rawMode: false,
|
rawMode: false,
|
||||||
jsonBody: openAiPayload,
|
jsonBody: openAiPayload,
|
||||||
};
|
};
|
||||||
}, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildGeminiBodyFromMessages, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]);
|
}, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]);
|
||||||
|
|
||||||
const buildModeProxyEnvelope = useCallback((): ProxyTestEnvelope | null => {
|
const buildModeProxyEnvelope = useCallback((): ProxyTestEnvelope | null => {
|
||||||
if (inputs.mode === 'embeddings') {
|
if (inputs.mode === 'embeddings') {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
RouteDecision,
|
RouteDecision,
|
||||||
RouteIconOption,
|
RouteIconOption,
|
||||||
MissingTokenRouteSiteActionItem,
|
MissingTokenRouteSiteActionItem,
|
||||||
|
MissingTokenGroupRouteSiteActionItem,
|
||||||
GroupRouteItem,
|
GroupRouteItem,
|
||||||
} from './token-routes/types.js';
|
} from './token-routes/types.js';
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +60,7 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
|
|||||||
tokenOptionsByAccountId: {},
|
tokenOptionsByAccountId: {},
|
||||||
};
|
};
|
||||||
const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = [];
|
const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = [];
|
||||||
|
const EMPTY_MISSING_GROUP_ITEMS: MissingTokenGroupRouteSiteActionItem[] = [];
|
||||||
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
|
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
|
||||||
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
|
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
|
||||||
];
|
];
|
||||||
@@ -68,6 +70,7 @@ export default function TokenRoutes() {
|
|||||||
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
||||||
const [modelCandidates, setModelCandidates] = useState<RouteModelCandidatesByModelName>({});
|
const [modelCandidates, setModelCandidates] = useState<RouteModelCandidatesByModelName>({});
|
||||||
const [missingTokenModelsByName, setMissingTokenModelsByName] = useState<MissingTokenModelsByName>({});
|
const [missingTokenModelsByName, setMissingTokenModelsByName] = useState<MissingTokenModelsByName>({});
|
||||||
|
const [missingTokenGroupModelsByName, setMissingTokenGroupModelsByName] = useState<MissingTokenModelsByName>({});
|
||||||
const [endpointTypesByModel, setEndpointTypesByModel] = useState<Record<string, string[]>>({});
|
const [endpointTypesByModel, setEndpointTypesByModel] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -189,6 +192,9 @@ export default function TokenRoutes() {
|
|||||||
setMissingTokenModelsByName(
|
setMissingTokenModelsByName(
|
||||||
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
|
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
|
||||||
);
|
);
|
||||||
|
setMissingTokenGroupModelsByName(
|
||||||
|
normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName),
|
||||||
|
);
|
||||||
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
||||||
const decisionPlaceholder: Record<number, RouteDecision | null> = {};
|
const decisionPlaceholder: Record<number, RouteDecision | null> = {};
|
||||||
for (const route of summaries) {
|
for (const route of summaries) {
|
||||||
@@ -586,7 +592,12 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
list = list.filter((route) => route.modelPattern.toLowerCase().includes(q));
|
list = list.filter((route) => {
|
||||||
|
const modelPattern = route.modelPattern.toLowerCase();
|
||||||
|
const displayName = (route.displayName || '').toLowerCase();
|
||||||
|
const title = resolveRouteTitle(route).toLowerCase();
|
||||||
|
return modelPattern.includes(q) || displayName.includes(q) || title.includes(q);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
@@ -636,6 +647,10 @@ export default function TokenRoutes() {
|
|||||||
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
|
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
|
||||||
[routePatterns, missingTokenModelsByName],
|
[routePatterns, missingTokenModelsByName],
|
||||||
);
|
);
|
||||||
|
const routeMissingTokenGroupIndex = useMemo(
|
||||||
|
() => buildRouteMissingTokenIndex(routePatterns, missingTokenGroupModelsByName, matchesModelPattern),
|
||||||
|
[routePatterns, missingTokenGroupModelsByName],
|
||||||
|
);
|
||||||
|
|
||||||
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
|
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
|
||||||
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||||
@@ -782,6 +797,56 @@ export default function TokenRoutes() {
|
|||||||
return result;
|
return result;
|
||||||
}, [routeMissingTokenIndex]);
|
}, [routeMissingTokenIndex]);
|
||||||
|
|
||||||
|
const missingTokenGroupItemsByRouteId = useMemo(() => {
|
||||||
|
const result: Record<number, MissingTokenGroupRouteSiteActionItem[]> = {};
|
||||||
|
for (const routeId of Object.keys(routeMissingTokenGroupIndex).map(Number)) {
|
||||||
|
const missingGroupHints = routeMissingTokenGroupIndex[routeId] || [];
|
||||||
|
const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>();
|
||||||
|
for (const hint of missingGroupHints) {
|
||||||
|
for (const account of hint.accounts) {
|
||||||
|
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
|
||||||
|
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
|
||||||
|
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
|
||||||
|
const accountLabel = account.username || `account-${account.accountId}`;
|
||||||
|
const missingGroups = Array.isArray(account.missingGroups) ? account.missingGroups : [];
|
||||||
|
const requiredGroups = Array.isArray(account.requiredGroups) ? account.requiredGroups : [];
|
||||||
|
const availableGroups = Array.isArray(account.availableGroups) ? account.availableGroups : [];
|
||||||
|
const existing = siteMap.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
siteMap.set(key, {
|
||||||
|
key,
|
||||||
|
siteName,
|
||||||
|
accountId: account.accountId,
|
||||||
|
accountLabel,
|
||||||
|
missingGroups: [...missingGroups],
|
||||||
|
requiredGroups: [...requiredGroups],
|
||||||
|
availableGroups: [...availableGroups],
|
||||||
|
...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (account.accountId < existing.accountId) {
|
||||||
|
existing.accountId = account.accountId;
|
||||||
|
existing.accountLabel = accountLabel;
|
||||||
|
}
|
||||||
|
existing.missingGroups = Array.from(new Set([...existing.missingGroups, ...missingGroups]))
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||||
|
existing.requiredGroups = Array.from(new Set([...existing.requiredGroups, ...requiredGroups]))
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||||
|
existing.availableGroups = Array.from(new Set([...existing.availableGroups, ...availableGroups]))
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||||
|
if (account.groupCoverageUncertain === true) {
|
||||||
|
existing.groupCoverageUncertain = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[routeId] = Array.from(siteMap.values()).sort((a, b) => (
|
||||||
|
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [routeMissingTokenGroupIndex]);
|
||||||
|
|
||||||
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
|
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
|
||||||
const toggleExpandRef = useRef(toggleExpand);
|
const toggleExpandRef = useRef(toggleExpand);
|
||||||
toggleExpandRef.current = toggleExpand;
|
toggleExpandRef.current = toggleExpand;
|
||||||
@@ -1075,6 +1140,7 @@ export default function TokenRoutes() {
|
|||||||
onDeleteChannel={stableDeleteChannel}
|
onDeleteChannel={stableDeleteChannel}
|
||||||
onChannelDragEnd={stableChannelDragEnd}
|
onChannelDragEnd={stableChannelDragEnd}
|
||||||
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
||||||
|
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
|
||||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||||
onAddChannel={stableAddChannel}
|
onAddChannel={stableAddChannel}
|
||||||
expandedSourceGroupMap={expandedSourceGroupMap}
|
expandedSourceGroupMap={expandedSourceGroupMap}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
buildApiPayload,
|
buildApiPayload,
|
||||||
buildEmbeddingsRequestEnvelope,
|
buildEmbeddingsRequestEnvelope,
|
||||||
buildFileUploadRequestEnvelope,
|
buildFileUploadRequestEnvelope,
|
||||||
|
buildGeminiNativeConversationProxyEnvelope,
|
||||||
buildRawProxyRequestEnvelope,
|
buildRawProxyRequestEnvelope,
|
||||||
buildSearchRequestEnvelope,
|
buildSearchRequestEnvelope,
|
||||||
collectModelTesterModelNames,
|
collectModelTesterModelNames,
|
||||||
@@ -321,6 +322,39 @@ describe('modelTesterSession', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds gemini native proxy envelope without loading assistant placeholders', () => {
|
||||||
|
const payload = buildGeminiNativeConversationProxyEnvelope(
|
||||||
|
[
|
||||||
|
{ id: 'u1', role: 'user', content: 'hello', createAt: 1 },
|
||||||
|
{ id: 'a1', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
|
||||||
|
{ id: 'a2', role: 'assistant', content: 'partial', createAt: 3, status: MESSAGE_STATUS.INCOMPLETE },
|
||||||
|
{ id: 'a3', role: 'assistant', content: 'done', createAt: 4, status: MESSAGE_STATUS.COMPLETE },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
...DEFAULT_INPUTS,
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
protocol: 'gemini',
|
||||||
|
systemPrompt: 'system text',
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...DEFAULT_PARAMETER_ENABLED,
|
||||||
|
max_tokens: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.path).toBe('/gemini/v1beta/models/gemini-2.5-pro:generateContent?alt=sse');
|
||||||
|
expect(payload.jobMode).toBe(false);
|
||||||
|
expect(payload.jsonBody).toEqual({
|
||||||
|
systemInstruction: { parts: [{ text: 'system text' }] },
|
||||||
|
contents: [
|
||||||
|
{ role: 'user', parts: [{ text: 'hello' }] },
|
||||||
|
{ role: 'model', parts: [{ text: 'done' }] },
|
||||||
|
],
|
||||||
|
generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes conversation file parts into openai and responses payloads', () => {
|
it('serializes conversation file parts into openai and responses payloads', () => {
|
||||||
const message = {
|
const message = {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
|
|||||||
@@ -949,6 +949,19 @@ export const buildConversationRequestEnvelope = (
|
|||||||
jsonBody: buildConversationJsonBody(messages, inputs, parameterEnabled),
|
jsonBody: buildConversationJsonBody(messages, inputs, parameterEnabled),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const buildGeminiNativeConversationProxyEnvelope = (
|
||||||
|
messages: ChatMessage[],
|
||||||
|
inputs: ModelTesterInputs,
|
||||||
|
parameterEnabled: ParameterEnabled,
|
||||||
|
): TesterProxyEnvelope => {
|
||||||
|
const envelope = buildConversationRequestEnvelope(messages, { ...inputs, protocol: 'gemini' }, parameterEnabled);
|
||||||
|
return {
|
||||||
|
...envelope,
|
||||||
|
path: `/gemini${envelope.path}${inputs.stream ? '?alt=sse' : ''}`,
|
||||||
|
jobMode: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const buildEmbeddingsRequestEnvelope = (
|
export const buildEmbeddingsRequestEnvelope = (
|
||||||
inputText: string,
|
inputText: string,
|
||||||
inputs: ModelTesterInputs,
|
inputs: ModelTesterInputs,
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
|
|||||||
isExactModelPattern: (pattern: string) => boolean,
|
isExactModelPattern: (pattern: string) => boolean,
|
||||||
matchesModelPattern: (model: string, pattern: string) => boolean,
|
matchesModelPattern: (model: string, pattern: string) => boolean,
|
||||||
): T[] {
|
): T[] {
|
||||||
|
const exactModelNames = new Set(
|
||||||
|
routes
|
||||||
|
.filter((route) => isExactModelPattern(route.modelPattern))
|
||||||
|
.map((route) => (route.modelPattern || '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
const coveringGroups = routes.filter((route) => (
|
const coveringGroups = routes.filter((route) => (
|
||||||
route.enabled
|
route.enabled
|
||||||
&& !isExactModelPattern(route.modelPattern)
|
&& !isExactModelPattern(route.modelPattern)
|
||||||
@@ -33,6 +39,7 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
|
|||||||
|
|
||||||
return !coveringGroups.some((groupRoute) => (
|
return !coveringGroups.some((groupRoute) => (
|
||||||
groupRoute.id !== route.id
|
groupRoute.id !== route.id
|
||||||
|
&& !exactModelNames.has((groupRoute.displayName || '').trim())
|
||||||
&& matchesModelPattern(exactModel, groupRoute.modelPattern)
|
&& matchesModelPattern(exactModel, groupRoute.modelPattern)
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ export type MissingTokenModelAccount = {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
|
missingGroups?: string[];
|
||||||
|
requiredGroups?: string[];
|
||||||
|
availableGroups?: string[];
|
||||||
|
groupCoverageUncertain?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
|
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
|
||||||
@@ -29,11 +33,21 @@ export function normalizeMissingTokenModels(
|
|||||||
if (!account || !Number.isFinite(account.accountId)) continue;
|
if (!account || !Number.isFinite(account.accountId)) continue;
|
||||||
const accountName = (account.username || '').trim();
|
const accountName = (account.username || '').trim();
|
||||||
const siteName = String(account.siteName || '').trim();
|
const siteName = String(account.siteName || '').trim();
|
||||||
|
const normalizeLabels = (labels: unknown): string[] => Array.isArray(labels)
|
||||||
|
? Array.from(new Set(labels
|
||||||
|
.map((label) => String(label || '').trim())
|
||||||
|
.filter(Boolean)))
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||||
|
: [];
|
||||||
accountMap.set(account.accountId, {
|
accountMap.set(account.accountId, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
username: accountName || null,
|
username: accountName || null,
|
||||||
siteId: account.siteId,
|
siteId: account.siteId,
|
||||||
siteName,
|
siteName,
|
||||||
|
...(normalizeLabels(account.missingGroups).length > 0 ? { missingGroups: normalizeLabels(account.missingGroups) } : {}),
|
||||||
|
...(normalizeLabels(account.requiredGroups).length > 0 ? { requiredGroups: normalizeLabels(account.requiredGroups) } : {}),
|
||||||
|
...(normalizeLabels(account.availableGroups).length > 0 ? { availableGroups: normalizeLabels(account.availableGroups) } : {}),
|
||||||
|
...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (accountMap.size > 0) {
|
if (accountMap.size > 0) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
RouteDecision,
|
RouteDecision,
|
||||||
RouteDecisionCandidate,
|
RouteDecisionCandidate,
|
||||||
MissingTokenRouteSiteActionItem,
|
MissingTokenRouteSiteActionItem,
|
||||||
|
MissingTokenGroupRouteSiteActionItem,
|
||||||
RouteRoutingStrategy,
|
RouteRoutingStrategy,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
||||||
@@ -61,6 +62,7 @@ type RouteCardProps = {
|
|||||||
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
|
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
|
||||||
// Missing token hints
|
// Missing token hints
|
||||||
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
|
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
|
||||||
|
missingTokenGroupItems: MissingTokenGroupRouteSiteActionItem[];
|
||||||
onCreateTokenForMissing: (accountId: number, modelName: string) => void;
|
onCreateTokenForMissing: (accountId: number, modelName: string) => void;
|
||||||
// Add channel
|
// Add channel
|
||||||
onAddChannel: (routeId: number) => void;
|
onAddChannel: (routeId: number) => void;
|
||||||
@@ -104,6 +106,7 @@ function RouteCardInner({
|
|||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onChannelDragEnd,
|
onChannelDragEnd,
|
||||||
missingTokenSiteItems,
|
missingTokenSiteItems,
|
||||||
|
missingTokenGroupItems,
|
||||||
onCreateTokenForMissing,
|
onCreateTokenForMissing,
|
||||||
onAddChannel,
|
onAddChannel,
|
||||||
expandedSourceGroupMap,
|
expandedSourceGroupMap,
|
||||||
@@ -298,9 +301,11 @@ function RouteCardInner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Missing token hints + Add channel button */}
|
{/* Missing token hints + Add channel button */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||||
{missingTokenSiteItems.length > 0 ? (
|
{(missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', flex: 1 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
|
||||||
|
{missingTokenSiteItems.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
|
||||||
{missingTokenSiteItems.map((item) => (
|
{missingTokenSiteItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
@@ -315,6 +320,25 @@ function RouteCardInner({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{missingTokenGroupItems.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('缺少分组')}:</span>
|
||||||
|
{missingTokenGroupItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={`missing-group-${route.id}-${item.key}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
|
||||||
|
className="badge badge-warning missing-token-group-tag"
|
||||||
|
data-tooltip={`缺少分组:${item.missingGroups.join('、') || '未知'}${item.availableGroups.length > 0 ? `;已覆盖:${item.availableGroups.join('、')}` : ''}${item.groupCoverageUncertain ? ';当前分组覆盖存在不确定性' : ''}`}
|
||||||
|
style={{ fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{item.siteName}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : <div />}
|
) : <div />}
|
||||||
<button
|
<button
|
||||||
onClick={() => onAddChannel(route.id)}
|
onClick={() => onAddChannel(route.id)}
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ export type MissingTokenRouteSiteActionItem = {
|
|||||||
accountLabel: string;
|
accountLabel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MissingTokenGroupRouteSiteActionItem = {
|
||||||
|
key: string;
|
||||||
|
siteName: string;
|
||||||
|
accountId: number;
|
||||||
|
accountLabel: string;
|
||||||
|
missingGroups: string[];
|
||||||
|
requiredGroups: string[];
|
||||||
|
availableGroups: string[];
|
||||||
|
groupCoverageUncertain?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type SortableChannelRowProps = {
|
export type SortableChannelRowProps = {
|
||||||
channel: RouteChannel;
|
channel: RouteChannel;
|
||||||
decisionCandidate?: RouteDecisionCandidate;
|
decisionCandidate?: RouteDecisionCandidate;
|
||||||
|
|||||||
@@ -289,6 +289,63 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders missing-token-group hints separately from missing-token site tags', async () => {
|
||||||
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6',
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||||
|
models: {},
|
||||||
|
modelsMissingTokenGroups: {
|
||||||
|
'claude-opus-4-6': [
|
||||||
|
{
|
||||||
|
accountId: 101,
|
||||||
|
username: 'linuxdo_4677',
|
||||||
|
siteId: 11,
|
||||||
|
siteName: '香草api',
|
||||||
|
missingGroups: ['opus'],
|
||||||
|
requiredGroups: ['default', 'opus'],
|
||||||
|
availableGroups: ['default'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.getRouteChannels.mockResolvedValue([]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const expandBtn = root.root.find((node) =>
|
||||||
|
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
expandBtn.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const text = collectText(root.root);
|
||||||
|
expect(text).toContain('缺少分组');
|
||||||
|
expect(text).toContain('香草api');
|
||||||
|
expect(text).not.toContain('待注册站点');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('maps endpoint types to expected brand icons in filter panel', async () => {
|
it('maps endpoint types to expected brand icons in filter panel', async () => {
|
||||||
apiMock.getRoutesSummary.mockResolvedValue([
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -518,6 +575,86 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps exact routes visible when a group display name collides with a real exact model', async () => {
|
||||||
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, modelPattern: 'official/gpt-4o-mini', displayName: 'official/gpt-4o-mini',
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, modelPattern: 're:^(gpt-4o-mini|official/gpt-4o-mini)$', displayName: 'gpt-4o-mini',
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const normalizedText = collectText(root.root).replace(/\s+/g, '');
|
||||||
|
expect(normalizedText).toContain('共3条路由');
|
||||||
|
expect(normalizedText).not.toContain('共1条路由');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches routes by display name as well as model pattern', async () => {
|
||||||
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group',
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const searchInput = findInputByPlaceholder(root.root, '搜索模型路由');
|
||||||
|
await act(async () => {
|
||||||
|
searchInput.props.onChange({ target: { value: 'claude-4-6-group' } });
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const normalizedText = collectText(root.root).replace(/\s+/g, '');
|
||||||
|
expect(normalizedText).toContain('共1条路由');
|
||||||
|
expect(normalizedText).not.toContain('没有匹配的路由');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('enters edit mode and seeds the group form with the current route values', async () => {
|
it('enters edit mode and seeds the group form with the current route values', async () => {
|
||||||
apiMock.getRoutesSummary.mockResolvedValue([
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user