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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user