feat: Add support for OpenAI Responses endpoint and enhance proxy retry logic

- Updated proxyRetryPolicy to retry on all 4xx and 5xx status codes.
- Introduced 'responses' target format in TestChatRequestPayload.
- Implemented extractResponsesContent function to handle responses format in ModelTester.
- Enhanced ModelTester to support responses target format and display appropriate labels.
- Updated ProxyLogs to extract and display upstream paths from error messages.
- Added tests to ensure responses target format is preserved during payload sync.
- Created responses proxy route to handle requests to the new OpenAI Responses endpoint.
- Updated upstreamEndpoint logic to include responses endpoint handling.
- Added modelService tests to validate token route rebuilding logic.
This commit is contained in:
cita
2026-03-01 15:21:16 +08:00
parent 44af9a91cf
commit 4e22b379a8
21 changed files with 1794 additions and 56 deletions
+123
View File
@@ -0,0 +1,123 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { and, eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type ModelServiceModule = typeof import('./modelService.js');
describe('rebuildTokenRoutesFromAvailability', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let rebuildTokenRoutesFromAvailability: ModelServiceModule['rebuildTokenRoutesFromAvailability'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-model-service-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const modelService = await import('./modelService.js');
db = dbModule.db;
schema = dbModule.schema;
rebuildTokenRoutesFromAvailability = modelService.rebuildTokenRoutesFromAvailability;
});
beforeEach(() => {
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(() => {
delete process.env.DATA_DIR;
});
it('removes stale exact routes and keeps wildcard routes on rebuild', () => {
const site = db.insert(schema.sites).values({
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-1',
accessToken: 'access-1',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-test',
source: 'manual',
enabled: true,
isDefault: true,
}).returning().get();
db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'latest-model',
available: true,
}).run();
const staleRoute = db.insert(schema.tokenRoutes).values({
modelPattern: 'old-model',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
routeId: staleRoute.id,
accountId: account.id,
tokenId: token.id,
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
}).run();
const wildcardRoute = db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-*',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
routeId: wildcardRoute.id,
accountId: account.id,
tokenId: token.id,
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
}).run();
const rebuild = rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
expect(rebuild.removedRoutes).toBe(1);
const oldRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, staleRoute.id)).get();
expect(oldRoute).toBeUndefined();
const oldChannels = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, staleRoute.id)).all();
expect(oldChannels).toHaveLength(0);
const latestRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.modelPattern, 'latest-model')).get();
expect(latestRoute).toBeDefined();
const latestChannels = db.select().from(schema.routeChannels)
.where(and(eq(schema.routeChannels.routeId, latestRoute!.id), eq(schema.routeChannels.tokenId, token.id)))
.all();
expect(latestChannels.length).toBeGreaterThan(0);
const wildcardRouteAfter = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, wildcardRoute.id)).get();
expect(wildcardRouteAfter).toBeDefined();
});
});
+24
View File
@@ -16,6 +16,10 @@ function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
}
function isExactModelPattern(modelPattern: string): boolean {
return !/[\*\?\[]/.test(modelPattern);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
@@ -214,6 +218,7 @@ export function rebuildTokenRoutesFromAvailability() {
let createdRoutes = 0;
let createdChannels = 0;
let removedChannels = 0;
let removedRoutes = 0;
for (const [modelName, tokenAccountMap] of modelTokens.entries()) {
let route = routes.find((r) => r.modelPattern === modelName);
@@ -269,11 +274,30 @@ export function rebuildTokenRoutesFromAvailability() {
}
}
const latestModelNames = new Set<string>(Array.from(modelTokens.keys()));
for (const route of routes) {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
continue;
}
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
if (routeChannelCount > 0) {
removedChannels += routeChannelCount;
}
const deleted = db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run().changes;
if (deleted > 0) {
removedRoutes += deleted;
}
}
return {
models: modelTokens.size,
createdRoutes,
createdChannels,
removedChannels,
removedRoutes,
};
}
+10 -4
View File
@@ -20,15 +20,21 @@ describe('proxyRetryPolicy', () => {
).toBe(true);
});
it('does not retry on generic client request errors', () => {
it('retries on generic client request errors for cross-channel fallback', () => {
expect(
shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'),
).toBe(false);
).toBe(true);
expect(
shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'),
).toBe(false);
).toBe(true);
expect(
shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'),
).toBe(false);
).toBe(true);
expect(
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
).toBe(true);
expect(
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
).toBe(true);
});
});
+2 -2
View File
@@ -21,7 +21,7 @@ function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
}
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
if (status === 429 || status >= 500) return true;
if (status !== 400 && status !== 404 && status !== 422) return false;
if (status >= 400) return true;
if (!upstreamErrorText) return false;
return isModelUnsupportedErrorMessage(upstreamErrorText);
}