From 23922a59655a7c2cedd7a49fbd866005728ba147 Mon Sep 17 00:00:00 2001 From: cita Date: Sun, 8 Mar 2026 12:08:57 +0800 Subject: [PATCH] feat: Add normalized types and utility functions for handling chat formats - Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling. - Implemented utility functions for creating and merging normalized usage statistics. - Added a new module for shared transformers to manage chat formats. feat: Implement app local state management - Created functions to clear application installation state and manage local storage keys. - Added constants for theme and user profile storage keys. test: Add tests for BrandIcon component rendering - Implemented tests to ensure BrandIcon does not force a colored background for recognized brands. test: Enhance ModelAnalysisPanel tests - Added tests for rendering token summaries with compact units in ModelAnalysisPanel. test: Add tests for ModernSelect component - Implemented tests to verify icon rendering for selected options in ModernSelect. test: Add number formatting tests - Created tests for `formatCompactTokenMetric` to validate token formatting logic. feat: Implement account creation intent handling - Added logic to handle session and API key creation intents in the Accounts page. test: Add tests for dashboard performance card - Implemented tests to verify rendering of performance metrics in the dashboard. feat: Implement default connection segment resolution - Added logic to determine initial connection segments based on platform types. test: Add tests for settings factory reset functionality - Implemented tests to verify the factory reset process and local state clearing. test: Add tests for site creation redirect logic - Implemented tests to verify redirection based on site capabilities during creation. test: Add tests for token routes refresh decision action - Implemented tests to verify refresh decision actions in TokenRoutes. test: Add tests for token routes snapshot load - Implemented tests to verify loading of cached snapshot data in TokenRoutes. --- src/server/db/index.ts | 53 + src/server/db/schema.ts | 24 + src/server/index.ts | 2 + src/server/middleware/auth.ts | 14 +- .../routes/api/settings.factory-reset.test.ts | 186 ++ src/server/routes/api/settings.ts | 15 + .../routes/api/stats.siteStatus.test.ts | 91 +- src/server/routes/api/stats.ts | 17 + src/server/routes/api/test.proxy.test.ts | 152 ++ src/server/routes/api/test.test.ts | 145 ++ src/server/routes/api/test.ts | 741 ++++++-- ...ens.route-decision-refresh-pricing.test.ts | 244 +++ .../tokens.route-decision-snapshot.test.ts | 179 ++ src/server/routes/api/tokens.ts | 269 ++- .../proxy/architecture-boundaries.test.ts | 41 + src/server/routes/proxy/chat.ts | 140 +- src/server/routes/proxy/chatFormats.ts | 1562 +---------------- src/server/routes/proxy/gemini.test.ts | 430 +++++ src/server/routes/proxy/gemini.ts | 196 +++ src/server/routes/proxy/images.edits.test.ts | 160 ++ src/server/routes/proxy/images.ts | 154 +- src/server/routes/proxy/models.test.ts | 86 + src/server/routes/proxy/models.ts | 10 +- src/server/routes/proxy/multipart.ts | 71 + src/server/routes/proxy/responses.ts | 917 +--------- src/server/routes/proxy/router.ts | 6 + src/server/routes/proxy/search.test.ts | 197 +++ src/server/routes/proxy/search.ts | 180 ++ .../routes/proxy/upstreamEndpoint.test.ts | 357 ++++ src/server/routes/proxy/upstreamEndpoint.ts | 673 +------ src/server/routes/proxy/videos.test.ts | 248 +++ src/server/routes/proxy/videos.ts | 229 +++ .../services/databaseMigrationService.ts | 13 +- .../services/defaultSiteSeedService.test.ts | 129 ++ src/server/services/defaultSiteSeedService.ts | 105 ++ .../services/factoryResetService.test.ts | 90 + src/server/services/factoryResetService.ts | 61 + src/server/services/modelPricingService.ts | 33 +- src/server/services/modelService.ts | 21 +- src/server/services/proxyVideoTaskStore.ts | 127 ++ .../services/routeDecisionSnapshotStore.ts | 64 + src/server/services/tokenRouter.ts | 123 +- .../anthropic/messages/aggregator.ts | 179 ++ .../anthropic/messages/conversion.test.ts | 666 +++++++ .../anthropic/messages/conversion.ts | 881 ++++++++++ .../anthropic/messages/inbound.ts | 22 + .../transformers/anthropic/messages/index.ts | 95 + .../anthropic/messages/outbound.ts | 10 + .../transformers/anthropic/messages/stream.ts | 700 ++++++++ .../transformers/anthropic/messages/usage.ts | 113 ++ .../final-hard-cut.architecture.test.ts | 40 + .../gemini/generate-content/aggregator.ts | 182 ++ .../gemini/generate-content/convert.ts | 165 ++ .../gemini/generate-content/inbound.ts | 119 ++ .../gemini/generate-content/index.test.ts | 439 +++++ .../gemini/generate-content/index.ts | 75 + .../gemini/generate-content/outbound.ts | 190 ++ .../gemini/generate-content/stream.ts | 67 + .../gemini/generate-content/usage.ts | 54 + .../transformers/openai/chat/aggregator.ts | 121 ++ .../transformers/openai/chat/helpers.ts | 238 +++ .../transformers/openai/chat/inbound.ts | 19 + .../transformers/openai/chat/index.test.ts | 234 +++ src/server/transformers/openai/chat/index.ts | 59 + src/server/transformers/openai/chat/model.ts | 44 + .../transformers/openai/chat/outbound.ts | 99 ++ src/server/transformers/openai/chat/stream.ts | 109 ++ src/server/transformers/openai/chat/usage.ts | 6 + .../openai/responses/aggregator.test.ts | 336 ++++ .../openai/responses/aggregator.ts | 762 ++++++++ .../openai/responses/compatibility.ts | 409 +++++ .../openai/responses/conversion.test.ts | 261 +++ .../openai/responses/conversion.ts | 698 ++++++++ .../transformers/openai/responses/inbound.ts | 6 + .../transformers/openai/responses/index.ts | 66 + .../transformers/openai/responses/outbound.ts | 182 ++ .../transformers/openai/responses/stream.ts | 80 + .../transformers/openai/responses/usage.ts | 6 + .../transformers/shared/chatFormatsCore.ts | 1541 ++++++++++++++++ .../transformers/shared/normalized.test.ts | 110 ++ src/server/transformers/shared/normalized.ts | 136 ++ src/web/App.tsx | 12 +- src/web/api.ts | 100 +- src/web/appLocalState.ts | 28 + src/web/components/BrandIcon.render.test.tsx | 14 + src/web/components/BrandIcon.test.ts | 34 + src/web/components/BrandIcon.tsx | 805 ++++++--- .../components/ModelAnalysisPanel.test.tsx | 68 + src/web/components/ModelAnalysisPanel.tsx | 3 +- src/web/components/ModernSelect.test.tsx | 32 + src/web/components/ModernSelect.tsx | 174 +- src/web/index.css | 113 ++ src/web/numberFormat.test.ts | 17 + src/web/numberFormat.ts | 27 + src/web/pages/Accounts.tsx | 11 +- src/web/pages/Dashboard.tsx | 63 +- src/web/pages/ModelTester.tsx | 1056 +++++++++-- .../pages/Models.marketplace-text.test.tsx | 71 + src/web/pages/Models.tsx | 11 +- src/web/pages/Settings.tsx | 102 +- src/web/pages/Sites.tsx | 14 +- src/web/pages/TokenRoutes.tsx | 90 +- src/web/pages/accounts.create-intent.test.tsx | 94 + .../accounts.segmented-connections.test.tsx | 29 + .../pages/dashboard.performance-card.test.tsx | 151 ++ .../helpers/defaultConnectionSegment.test.ts | 26 + .../pages/helpers/defaultConnectionSegment.ts | 25 + .../pages/helpers/modelTesterSession.test.ts | 251 +-- src/web/pages/helpers/modelTesterSession.ts | 920 +++++++--- src/web/pages/settings.factory-reset.test.tsx | 172 ++ src/web/pages/settings.system-proxy.test.tsx | 3 +- src/web/pages/sites.create-redirect.test.tsx | 126 ++ .../pages/tokenRoutes.group-collapse.test.tsx | 53 +- .../tokenRoutes.refresh-decision.test.tsx | 109 ++ .../pages/tokenRoutes.snapshot-load.test.tsx | 130 ++ 115 files changed, 18597 insertions(+), 4411 deletions(-) create mode 100644 src/server/routes/api/settings.factory-reset.test.ts create mode 100644 src/server/routes/api/test.proxy.test.ts create mode 100644 src/server/routes/api/test.test.ts create mode 100644 src/server/routes/api/tokens.route-decision-refresh-pricing.test.ts create mode 100644 src/server/routes/api/tokens.route-decision-snapshot.test.ts create mode 100644 src/server/routes/proxy/architecture-boundaries.test.ts create mode 100644 src/server/routes/proxy/gemini.test.ts create mode 100644 src/server/routes/proxy/gemini.ts create mode 100644 src/server/routes/proxy/images.edits.test.ts create mode 100644 src/server/routes/proxy/multipart.ts create mode 100644 src/server/routes/proxy/search.test.ts create mode 100644 src/server/routes/proxy/search.ts create mode 100644 src/server/routes/proxy/videos.test.ts create mode 100644 src/server/routes/proxy/videos.ts create mode 100644 src/server/services/defaultSiteSeedService.test.ts create mode 100644 src/server/services/defaultSiteSeedService.ts create mode 100644 src/server/services/factoryResetService.test.ts create mode 100644 src/server/services/factoryResetService.ts create mode 100644 src/server/services/proxyVideoTaskStore.ts create mode 100644 src/server/services/routeDecisionSnapshotStore.ts create mode 100644 src/server/transformers/anthropic/messages/aggregator.ts create mode 100644 src/server/transformers/anthropic/messages/conversion.test.ts create mode 100644 src/server/transformers/anthropic/messages/conversion.ts create mode 100644 src/server/transformers/anthropic/messages/inbound.ts create mode 100644 src/server/transformers/anthropic/messages/index.ts create mode 100644 src/server/transformers/anthropic/messages/outbound.ts create mode 100644 src/server/transformers/anthropic/messages/stream.ts create mode 100644 src/server/transformers/anthropic/messages/usage.ts create mode 100644 src/server/transformers/final-hard-cut.architecture.test.ts create mode 100644 src/server/transformers/gemini/generate-content/aggregator.ts create mode 100644 src/server/transformers/gemini/generate-content/convert.ts create mode 100644 src/server/transformers/gemini/generate-content/inbound.ts create mode 100644 src/server/transformers/gemini/generate-content/index.test.ts create mode 100644 src/server/transformers/gemini/generate-content/index.ts create mode 100644 src/server/transformers/gemini/generate-content/outbound.ts create mode 100644 src/server/transformers/gemini/generate-content/stream.ts create mode 100644 src/server/transformers/gemini/generate-content/usage.ts create mode 100644 src/server/transformers/openai/chat/aggregator.ts create mode 100644 src/server/transformers/openai/chat/helpers.ts create mode 100644 src/server/transformers/openai/chat/inbound.ts create mode 100644 src/server/transformers/openai/chat/index.test.ts create mode 100644 src/server/transformers/openai/chat/index.ts create mode 100644 src/server/transformers/openai/chat/model.ts create mode 100644 src/server/transformers/openai/chat/outbound.ts create mode 100644 src/server/transformers/openai/chat/stream.ts create mode 100644 src/server/transformers/openai/chat/usage.ts create mode 100644 src/server/transformers/openai/responses/aggregator.test.ts create mode 100644 src/server/transformers/openai/responses/aggregator.ts create mode 100644 src/server/transformers/openai/responses/compatibility.ts create mode 100644 src/server/transformers/openai/responses/conversion.test.ts create mode 100644 src/server/transformers/openai/responses/conversion.ts create mode 100644 src/server/transformers/openai/responses/inbound.ts create mode 100644 src/server/transformers/openai/responses/index.ts create mode 100644 src/server/transformers/openai/responses/outbound.ts create mode 100644 src/server/transformers/openai/responses/stream.ts create mode 100644 src/server/transformers/openai/responses/usage.ts create mode 100644 src/server/transformers/shared/chatFormatsCore.ts create mode 100644 src/server/transformers/shared/normalized.test.ts create mode 100644 src/server/transformers/shared/normalized.ts create mode 100644 src/web/appLocalState.ts create mode 100644 src/web/components/BrandIcon.render.test.tsx create mode 100644 src/web/components/ModelAnalysisPanel.test.tsx create mode 100644 src/web/components/ModernSelect.test.tsx create mode 100644 src/web/numberFormat.test.ts create mode 100644 src/web/numberFormat.ts create mode 100644 src/web/pages/accounts.create-intent.test.tsx create mode 100644 src/web/pages/dashboard.performance-card.test.tsx create mode 100644 src/web/pages/helpers/defaultConnectionSegment.test.ts create mode 100644 src/web/pages/helpers/defaultConnectionSegment.ts create mode 100644 src/web/pages/settings.factory-reset.test.tsx create mode 100644 src/web/pages/sites.create-redirect.test.tsx create mode 100644 src/web/pages/tokenRoutes.refresh-decision.test.tsx create mode 100644 src/web/pages/tokenRoutes.snapshot-load.test.tsx diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 2b6a83d..a1f3ab1 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -23,6 +23,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([ 'token_routes', 'route_channels', 'proxy_logs', + 'proxy_video_tasks', 'downstream_api_keys', 'events', ]); @@ -138,6 +139,49 @@ function ensureTokenManagementSchema() { `); } +function ensureProxyVideoTaskSchema() { + const sqlite = requireSqliteConnection(); + sqlite.exec(` + CREATE TABLE IF NOT EXISTS proxy_video_tasks ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + public_id text NOT NULL, + upstream_video_id text NOT NULL, + site_url text NOT NULL, + token_value text NOT NULL, + requested_model text, + actual_model text, + channel_id integer, + account_id integer, + status_snapshot text, + upstream_response_meta text, + last_upstream_status integer, + last_polled_at text, + created_at text DEFAULT (datetime('now')), + updated_at text DEFAULT (datetime('now')) + ); + `); + if (!tableColumnExists('proxy_video_tasks', 'status_snapshot')) { + sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN status_snapshot text;'); + } + if (!tableColumnExists('proxy_video_tasks', 'upstream_response_meta')) { + sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN upstream_response_meta text;'); + } + if (!tableColumnExists('proxy_video_tasks', 'last_upstream_status')) { + sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN last_upstream_status integer;'); + } + if (!tableColumnExists('proxy_video_tasks', 'last_polled_at')) { + sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN last_polled_at text;'); + } + sqlite.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique + ON proxy_video_tasks(public_id); + `); + sqlite.exec(` + CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx + ON proxy_video_tasks(upstream_video_id); + `); +} + function ensureSiteStatusSchema() { const sqlite = requireSqliteConnection(); if (!tableExists('sites')) { @@ -303,6 +347,14 @@ function ensureRouteGroupingSchema() { sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_icon text;`); } + if (!tableColumnExists('token_routes', 'decision_snapshot')) { + sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`); + } + + if (!tableColumnExists('token_routes', 'decision_refreshed_at')) { + sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;`); + } + if (!tableColumnExists('route_channels', 'source_model')) { sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`); } @@ -695,6 +747,7 @@ function initSqliteDb() { ensureRouteGroupingSchema(); ensureDownstreamApiKeySchema(); ensureProxyLogBillingDetailsSchema(); + ensureProxyVideoTaskSchema(); const rawDb = drizzleSqliteProxy( (sqlText, params, method) => sqliteProxyQuery(sqlText, params, method as SqlMethod), diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 461dd43..86f264c 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -109,6 +109,8 @@ export const tokenRoutes = sqliteTable('token_routes', { displayName: text('display_name'), displayIcon: text('display_icon'), modelMapping: text('model_mapping'), // JSON + decisionSnapshot: text('decision_snapshot'), // JSON + decisionRefreshedAt: text('decision_refreshed_at'), enabled: integer('enabled', { mode: 'boolean' }).default(true), createdAt: text('created_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`), @@ -167,6 +169,28 @@ export const proxyLogs = sqliteTable('proxy_logs', { modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt), })); +export const proxyVideoTasks = sqliteTable('proxy_video_tasks', { + id: integer('id').primaryKey({ autoIncrement: true }), + publicId: text('public_id').notNull(), + upstreamVideoId: text('upstream_video_id').notNull(), + siteUrl: text('site_url').notNull(), + tokenValue: text('token_value').notNull(), + requestedModel: text('requested_model'), + actualModel: text('actual_model'), + channelId: integer('channel_id'), + accountId: integer('account_id'), + statusSnapshot: text('status_snapshot'), + upstreamResponseMeta: text('upstream_response_meta'), + lastUpstreamStatus: integer('last_upstream_status'), + lastPolledAt: text('last_polled_at'), + createdAt: text('created_at').default(sql`(datetime('now'))`), + updatedAt: text('updated_at').default(sql`(datetime('now'))`), +}, (table) => ({ + publicIdUnique: uniqueIndex('proxy_video_tasks_public_id_unique').on(table.publicId), + upstreamVideoIdIdx: index('proxy_video_tasks_upstream_video_id_idx').on(table.upstreamVideoId), + createdAtIdx: index('proxy_video_tasks_created_at_idx').on(table.createdAt), +})); + export const settings = sqliteTable('settings', { key: text('key').primaryKey(), value: text('value'), // JSON diff --git a/src/server/index.ts b/src/server/index.ts index 783ea65..6efaccf 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,6 +23,7 @@ import { startProxyLogRetentionService, stopProxyLogRetentionService } from './s import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; +import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; @@ -222,6 +223,7 @@ try { await ensureProxyLogBillingDetailsColumn(); await repairStoredCreatedAtValues(); await migrateSiteApiKeysToAccounts(); + await ensureDefaultSitesSeeded(); console.log('Loaded runtime settings overrides'); } catch (error) { diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index edd7bd2..e333f4c 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -66,12 +66,22 @@ export async function proxyAuthMiddleware(request: FastifyRequest, reply: Fastif const apiKeyHeader = typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : ''; + const googApiKeyHeader = typeof request.headers['x-goog-api-key'] === 'string' + ? request.headers['x-goog-api-key'] + : ''; + const queryKey = ( + request.query + && typeof request.query === 'object' + && typeof (request.query as Record).key === 'string' + ) + ? String((request.query as Record).key).trim() + : ''; const token = auth ? auth.replace(/^Bearer\s+/i, '').trim() - : apiKeyHeader.trim(); + : (apiKeyHeader.trim() || googApiKeyHeader.trim() || queryKey); if (!token) { - reply.code(401).send({ error: 'Missing Authorization or x-api-key header' }); + reply.code(401).send({ error: 'Missing Authorization, x-api-key, x-goog-api-key, or key query parameter' }); return; } diff --git a/src/server/routes/api/settings.factory-reset.test.ts b/src/server/routes/api/settings.factory-reset.test.ts new file mode 100644 index 0000000..27ce2ed --- /dev/null +++ b/src/server/routes/api/settings.factory-reset.test.ts @@ -0,0 +1,186 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { eq } from 'drizzle-orm'; + +type DbModule = typeof import('../../db/index.js'); +type ConfigModule = typeof import('../../config.js'); +type ServiceModule = typeof import('../../services/factoryResetService.js'); + +describe('settings factory reset api', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let config: ConfigModule['config']; + let FACTORY_RESET_ADMIN_TOKEN: ServiceModule['FACTORY_RESET_ADMIN_TOKEN']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-factory-reset-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const configModule = await import('../../config.js'); + const serviceModule = await import('../../services/factoryResetService.js'); + const settingsRoutesModule = await import('./settings.js'); + + db = dbModule.db; + schema = dbModule.schema; + config = configModule.config; + FACTORY_RESET_ADMIN_TOKEN = serviceModule.FACTORY_RESET_ADMIN_TOKEN; + + app = Fastify(); + await app.register(settingsRoutesModule.settingsRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.proxyVideoTasks).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.sites).run(); + await db.delete(schema.downstreamApiKeys).run(); + await db.delete(schema.events).run(); + await db.delete(schema.settings).run(); + + config.authToken = 'before-reset-token'; + config.dbType = 'sqlite'; + config.dbUrl = ''; + config.dbSsl = true; + config.systemProxyUrl = 'http://127.0.0.1:7890'; + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('clears data, resets runtime config, and returns to initial sqlite state', async () => { + const siteInsert = await db.insert(schema.sites).values({ + name: 'Reset Me', + url: 'https://reset.example.com', + platform: 'new-api', + status: 'disabled', + }).run(); + const siteId = Number(siteInsert.lastInsertRowid); + + const accountInsert = await db.insert(schema.accounts).values({ + siteId, + accessToken: 'session-token', + status: 'expired', + }).run(); + const accountId = Number(accountInsert.lastInsertRowid); + + const tokenInsert = await db.insert(schema.accountTokens).values({ + accountId, + name: 'Reset token', + token: 'token-value', + }).run(); + const tokenId = Number(tokenInsert.lastInsertRowid); + + const routeInsert = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-*', + enabled: true, + }).run(); + const routeId = Number(routeInsert.lastInsertRowid); + + await db.insert(schema.routeChannels).values({ + routeId, + accountId, + tokenId, + enabled: true, + }).run(); + await db.insert(schema.modelAvailability).values({ + accountId, + modelName: 'gpt-4.1', + available: true, + }).run(); + await db.insert(schema.tokenModelAvailability).values({ + tokenId, + modelName: 'gpt-4.1', + available: true, + }).run(); + await db.insert(schema.checkinLogs).values({ + accountId, + status: 'success', + message: 'ok', + }).run(); + await db.insert(schema.proxyLogs).values({ + accountId, + status: 'success', + modelRequested: 'gpt-4.1', + }).run(); + await db.insert(schema.proxyVideoTasks).values({ + publicId: 'public-video-1', + upstreamVideoId: 'upstream-video-1', + siteUrl: 'https://reset.example.com', + tokenValue: 'session-token', + }).run(); + await db.insert(schema.downstreamApiKeys).values({ + name: 'Downstream', + key: 'downstream-key', + }).run(); + await db.insert(schema.events).values({ + type: 'status', + title: 'Before reset', + message: 'should be gone', + level: 'warning', + }).run(); + await db.insert(schema.settings).values([ + { key: 'auth_token', value: JSON.stringify('before-reset-token') }, + { key: 'db_type', value: JSON.stringify('postgres') }, + { key: 'db_url', value: JSON.stringify('postgres://user:pass@127.0.0.1:5432/metapi') }, + { key: 'db_ssl', value: JSON.stringify(true) }, + { key: 'system_proxy_url', value: JSON.stringify('http://127.0.0.1:7890') }, + ]).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/maintenance/factory-reset', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + expect(config.authToken).toBe(FACTORY_RESET_ADMIN_TOKEN); + expect(config.dbType).toBe('sqlite'); + expect(config.dbUrl).toBe(''); + expect(config.dbSsl).toBe(false); + + const sites = await db.select().from(schema.sites).all(); + expect(sites.map((site) => site.name)).toEqual([ + 'OpenAI 官方', + 'Claude 官方', + 'Gemini 官方', + 'CLIProxyAPI', + ]); + + const authTokenSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'auth_token')).get(); + const dbTypeSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_type')).get(); + const dbUrlSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_url')).get(); + const dbSslSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get(); + expect(authTokenSetting).toBeUndefined(); + expect(dbTypeSetting).toBeUndefined(); + expect(dbUrlSetting).toBeUndefined(); + expect(dbSslSetting).toBeUndefined(); + + expect(await db.select().from(schema.accounts).all()).toHaveLength(0); + expect(await db.select().from(schema.accountTokens).all()).toHaveLength(0); + expect(await db.select().from(schema.tokenRoutes).all()).toHaveLength(0); + expect(await db.select().from(schema.routeChannels).all()).toHaveLength(0); + expect(await db.select().from(schema.modelAvailability).all()).toHaveLength(0); + expect(await db.select().from(schema.tokenModelAvailability).all()).toHaveLength(0); + expect(await db.select().from(schema.proxyLogs).all()).toHaveLength(0); + expect(await db.select().from(schema.proxyVideoTasks).all()).toHaveLength(0); + expect(await db.select().from(schema.checkinLogs).all()).toHaveLength(0); + expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0); + expect(await db.select().from(schema.events).all()).toHaveLength(0); + }); +}); diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index e59a720..cba6159 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -17,6 +17,7 @@ import { import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js'; +import { performFactoryReset } from '../../services/factoryResetService.js'; type RoutingWeights = typeof config.routingWeights; @@ -910,4 +911,18 @@ export async function settingsRoutes(app: FastifyInstance) { deletedProxyLogs, }; }); + + app.post('/api/settings/maintenance/factory-reset', async (_, reply) => { + try { + await performFactoryReset(); + return { + success: true, + }; + } catch (err: any) { + return reply.code(500).send({ + success: false, + message: err?.message || '重新初始化系统失败', + }); + } + }); } diff --git a/src/server/routes/api/stats.siteStatus.test.ts b/src/server/routes/api/stats.siteStatus.test.ts index e05e292..75ca6c5 100644 --- a/src/server/routes/api/stats.siteStatus.test.ts +++ b/src/server/routes/api/stats.siteStatus.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mkdtempSync } from 'node:fs'; import { sql } from 'drizzle-orm'; -import { formatLocalDate } from '../../services/localTimeService.js'; +import { formatLocalDate, formatUtcSqlDateTime } from '../../services/localTimeService.js'; type DbModule = typeof import('../../db/index.js'); @@ -153,4 +153,93 @@ describe('stats dashboard filters disabled sites', () => { total: 3, }); }); + + it('returns recent RPM and TPM stats using only active-site proxy logs', async () => { + const activeSite = await db.insert(schema.sites).values({ + name: 'active-performance-site', + url: 'https://active-performance.example.com', + platform: 'new-api', + }).returning().get(); + + const disabledSite = await db.insert(schema.sites).values({ + name: 'disabled-performance-site', + url: 'https://disabled-performance.example.com', + platform: 'new-api', + }).returning().get(); + + await db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`); + + const activeAccount = await db.insert(schema.accounts).values({ + siteId: activeSite.id, + username: 'active-performance-user', + accessToken: 'active-performance-token', + balance: 100, + status: 'active', + }).returning().get(); + + const disabledAccount = await db.insert(schema.accounts).values({ + siteId: disabledSite.id, + username: 'disabled-performance-user', + accessToken: 'disabled-performance-token', + balance: 100, + status: 'active', + }).returning().get(); + + const now = Date.now(); + const withinWindow = formatUtcSqlDateTime(new Date(now - 30_000)); + const outsideWindow = formatUtcSqlDateTime(new Date(now - 90_000)); + + await db.insert(schema.proxyLogs).values([ + { + accountId: activeAccount.id, + status: 'success', + totalTokens: 1200, + createdAt: withinWindow, + }, + { + accountId: activeAccount.id, + status: 'failed', + totalTokens: 600, + createdAt: withinWindow, + }, + { + accountId: activeAccount.id, + status: 'success', + totalTokens: null, + createdAt: withinWindow, + }, + { + accountId: activeAccount.id, + status: 'success', + totalTokens: 9999, + createdAt: outsideWindow, + }, + { + accountId: disabledAccount.id, + status: 'success', + totalTokens: 7777, + createdAt: withinWindow, + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + performance?: { + windowSeconds: number; + requestsPerMinute: number; + tokensPerMinute: number; + }; + }; + + expect(body.performance).toEqual({ + windowSeconds: 60, + requestsPerMinute: 3, + tokensPerMinute: 1800, + }); + }); }); diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 322e2f1..85fc4df 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -115,6 +115,7 @@ export async function statsRoutes(app: FastifyInstance) { const nowTs = Date.now(); const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000)); + const lastMinuteDate = formatUtcSqlDateTime(new Date(nowTs - 60_000)); const last7dDate = getLocalRangeStartUtc(7); const recentProxyLogs = (await db.select({ proxy_logs: proxyLogBaseFields, @@ -145,6 +146,15 @@ export async function statsRoutes(app: FastifyInstance) { .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .where(and(gte(schema.proxyLogs.createdAt, last24hDate), eq(schema.sites.status, 'active'))) .get(); + const proxyPerformanceRow = await db.select({ + total: sql`count(*)`, + totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(and(gte(schema.proxyLogs.createdAt, lastMinuteDate), eq(schema.sites.status, 'active'))) + .get(); const todaySpendRow = await db.select({ todaySpend: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, }) @@ -162,6 +172,8 @@ export async function statsRoutes(app: FastifyInstance) { const proxyFailed = Number(proxy24hRow?.failed || 0); const proxyTotal = Number(proxy24hRow?.total || 0); const totalTokens = Number(proxy24hRow?.totalTokens || 0); + const requestsPerMinute = Number(proxyPerformanceRow?.total || 0); + const tokensPerMinute = Number(proxyPerformanceRow?.totalTokens || 0); const totalUsed = Number(totalUsedRow?.totalUsed || 0); const todaySpend = Number(todaySpendRow?.todaySpend || 0); const todayReward = accounts.reduce((sum, account) => sum + estimateRewardWithTodayIncomeFallback({ @@ -182,6 +194,11 @@ export async function statsRoutes(app: FastifyInstance) { totalAccounts: accounts.length, todayCheckin: { success: checkinSuccess, failed: checkinFailed, total: todayCheckins.length }, proxy24h: { success: proxySuccess, failed: proxyFailed, total: proxyTotal, totalTokens }, + performance: { + windowSeconds: 60, + requestsPerMinute, + tokensPerMinute, + }, modelAnalysis, }; }); diff --git a/src/server/routes/api/test.proxy.test.ts b/src/server/routes/api/test.proxy.test.ts new file mode 100644 index 0000000..713a0de --- /dev/null +++ b/src/server/routes/api/test.proxy.test.ts @@ -0,0 +1,152 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +describe('testRoutes proxy tester transport', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { testRoutes } = await import('./test.js'); + app = Fastify(); + await app.register(testRoutes); + }); + + beforeEach(() => { + fetchMock.mockReset(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('rejects proxy test paths outside the whitelist', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy', + payload: { + method: 'POST', + path: '/api/accounts', + requestKind: 'json', + stream: false, + jobMode: false, + rawMode: false, + jsonBody: {}, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: { + message: 'path is not allowed: /api/accounts', + type: 'validation_error', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('forwards raw json bodies without dropping unknown fields', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + object: 'response', + output_text: 'ok', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy', + payload: { + method: 'POST', + path: '/v1/responses', + requestKind: 'json', + stream: false, + jobMode: false, + rawMode: true, + rawJsonText: JSON.stringify({ + model: 'gpt-4o-mini', + include: ['reasoning.encrypted_content'], + metadata: { source: 'playground' }, + custom_field: 'keep-me', + }), + }, + }); + + expect(response.statusCode).toBe(200); + const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toMatch(/\/v1\/responses$/); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'gpt-4o-mini', + include: ['reasoning.encrypted_content'], + metadata: { source: 'playground' }, + custom_field: 'keep-me', + stream: false, + }); + }); + + it('translates legacy claude chat tests through the compatibility wrapper', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + type: 'message', + content: [{ type: 'text', text: 'pong' }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/test/chat', + payload: { + model: 'claude-3-7-sonnet', + targetFormat: 'claude', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'ping' }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toMatch(/\/v1\/messages$/); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'claude-3-7-sonnet', + stream: false, + max_tokens: 4096, + system: 'be concise', + messages: [ + { role: 'user', content: 'ping' }, + ], + }); + }); + + it('accepts stream=true when creating proxy jobs and stores a pending job', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy/jobs', + payload: { + method: 'POST', + path: '/v1/chat/completions', + requestKind: 'json', + stream: true, + jobMode: true, + rawMode: false, + jsonBody: { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'ping' }] }, + }, + }); + + expect(response.statusCode).toBe(202); + expect(response.json()).toMatchObject({ + status: 'pending', + }); + }); +}); diff --git a/src/server/routes/api/test.test.ts b/src/server/routes/api/test.test.ts new file mode 100644 index 0000000..acee17c --- /dev/null +++ b/src/server/routes/api/test.test.ts @@ -0,0 +1,145 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { config } from '../../config.js'; + +const fetchMock = vi.fn(); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +describe('/api/test proxy tester routes', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { testRoutes } = await import('./test.js'); + app = Fastify(); + await app.register(testRoutes); + }); + + beforeEach(() => { + fetchMock.mockReset(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('rejects proxy paths outside the allowlist', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy', + payload: { + method: 'POST', + path: '/admin/secret', + requestKind: 'json', + jsonBody: {}, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: { + message: 'path is not allowed: /admin/secret', + type: 'validation_error', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('forwards raw json bodies without dropping extra proxy fields', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + object: 'response', + output_text: 'ok', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const rawPayload = { + model: 'gpt-5.2', + include: ['reasoning.encrypted_content'], + reasoning: { effort: 'medium' }, + metadata: { source: 'tester' }, + }; + + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy', + payload: { + method: 'POST', + path: '/v1/responses', + requestKind: 'json', + rawMode: true, + rawJsonText: JSON.stringify(rawPayload), + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe(`http://127.0.0.1:${config.port}/v1/responses`); + expect(JSON.parse(String(requestInit.body))).toEqual({ + ...rawPayload, + stream: false, + }); + }); + + it('rejects empty multipart envelopes', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/test/proxy', + payload: { + method: 'POST', + path: '/v1/images/edits', + requestKind: 'multipart', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: { + message: 'multipart requests require multipartFields or multipartFiles', + type: 'validation_error', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps legacy /api/test/chat wrapper working for responses payloads', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + object: 'response', + output_text: 'hello', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/test/chat', + payload: { + model: 'gpt-5.2', + targetFormat: 'responses', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello' }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe(`http://127.0.0.1:${config.port}/v1/responses`); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'gpt-5.2', + stream: false, + input: [{ role: 'user', content: 'hello' }], + instructions: 'be concise', + max_output_tokens: 4096, + }); + }); +}); diff --git a/src/server/routes/api/test.ts b/src/server/routes/api/test.ts index 6e53ed3..8e94d65 100644 --- a/src/server/routes/api/test.ts +++ b/src/server/routes/api/test.ts @@ -1,10 +1,12 @@ -import { FastifyInstance, FastifyReply } from 'fastify'; +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { randomUUID } from 'node:crypto'; -import { fetch } from 'undici'; +import { fetch, File, FormData } from 'undici'; import { config } from '../../config.js'; +type UndiciRequestInit = Parameters[1]; + type TestChatMessage = { role: string; content: string }; -type TestTargetFormat = 'openai' | 'claude' | 'responses'; +type TestTargetFormat = 'openai' | 'claude' | 'responses' | 'gemini'; type TestChatRequestBody = { model?: string; @@ -32,12 +34,48 @@ type ValidatedTestChatPayload = { seed?: number; }; -type TestChatJobStatus = 'pending' | 'succeeded' | 'failed' | 'cancelled'; +type ProxyTestMethod = 'POST' | 'GET' | 'DELETE'; +type ProxyTestRequestKind = 'json' | 'multipart' | 'empty'; -type TestChatJob = { +type ProxyTestMultipartFile = { + field: string; + name: string; + mimeType: string; + dataUrl: string; +}; + +type ProxyTestEnvelope = { + method?: ProxyTestMethod; + path?: string; + requestKind?: ProxyTestRequestKind; + stream?: boolean; + jobMode?: boolean; + rawMode?: boolean; + jsonBody?: unknown; + rawJsonText?: string; + multipartFields?: Record; + multipartFiles?: ProxyTestMultipartFile[]; +}; + +type ValidatedProxyTestEnvelope = { + method: ProxyTestMethod; + path: string; + requestKind: ProxyTestRequestKind; + stream: boolean; + jobMode: boolean; + rawMode: boolean; + jsonBody?: unknown; + rawJsonText?: string; + multipartFields?: Record; + multipartFiles?: ProxyTestMultipartFile[]; +}; + +type TestJobStatus = 'pending' | 'succeeded' | 'failed' | 'cancelled'; + +type TestProxyJob = { id: string; - status: TestChatJobStatus; - payload: ValidatedTestChatPayload; + status: TestJobStatus; + envelope: ValidatedProxyTestEnvelope; result?: unknown; error?: unknown; controller?: AbortController | null; @@ -48,7 +86,22 @@ type TestChatJob = { const JOB_TTL_MS = 10 * 60 * 1000; const JOB_CLEANUP_INTERVAL_MS = 60 * 1000; -const jobs = new Map(); +const jobs = new Map(); + +const ALLOWED_PROXY_PATH_PATTERNS: RegExp[] = [ + /^\/v1\/chat\/completions(?:\?.*)?$/i, + /^\/v1\/responses(?:\/compact)?(?:\?.*)?$/i, + /^\/v1\/messages(?:\?.*)?$/i, + /^\/v1\/embeddings(?:\?.*)?$/i, + /^\/v1\/search(?:\?.*)?$/i, + /^\/v1\/images\/(?:generations|edits)(?:\?.*)?$/i, + /^\/v1\/videos(?:\?.*)?$/i, + /^\/v1\/videos\/[^/?#]+(?:\?.*)?$/i, + /^\/gemini\/[^/]+\/models(?:\?.*)?$/i, + /^\/gemini\/[^/]+\/models\/.+(?:\?.*)?$/i, + /^\/v1beta\/models(?:\?.*)?$/i, + /^\/v1beta\/models\/.+(?:\?.*)?$/i, +]; class UpstreamProxyError extends Error { statusCode: number; @@ -62,18 +115,41 @@ class UpstreamProxyError extends Error { } } -const normalizeErrorPayload = (text: string): unknown => { +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeErrorPayload(text: string): unknown { try { return JSON.parse(text); } catch { return { error: { message: text, type: 'upstream_error' } }; } -}; +} -const validatePayload = ( +function normalizeProxyPath(value: unknown): string { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + try { + if (/^https?:\/\//i.test(trimmed)) { + const url = new URL(trimmed); + return `${url.pathname}${url.search}`; + } + } catch { + // ignore invalid absolute URL + } + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} + +function isAllowedProxyPath(path: string): boolean { + return ALLOWED_PROXY_PATH_PATTERNS.some((pattern) => pattern.test(path)); +} + +function validateLegacyPayload( body: TestChatRequestBody, reply: FastifyReply, -): ValidatedTestChatPayload | null => { +): ValidatedTestChatPayload | null { if (!body.model || body.model.trim().length === 0) { reply.code(400).send({ error: 'model is required' }); return null; @@ -88,7 +164,9 @@ const validatePayload = ( ? 'claude' : body.targetFormat === 'responses' ? 'responses' - : 'openai'; + : body.targetFormat === 'gemini' + ? 'gemini' + : 'openai'; return { model: body.model, @@ -102,12 +180,12 @@ const validatePayload = ( presence_penalty: body.presence_penalty, seed: body.seed, }; -}; +} -const convertOpenAiPayloadToClaudeBody = ( +function convertOpenAiPayloadToClaudeBody( payload: ValidatedTestChatPayload, forceStream: boolean, -): Record => { +): Record { const systemContents: string[] = []; const messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; @@ -136,10 +214,7 @@ const convertOpenAiPayloadToClaudeBody = ( messages, }; - if (systemContents.length > 0) { - body.system = systemContents.join('\n\n'); - } - + if (systemContents.length > 0) body.system = systemContents.join('\n\n'); if (typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)) { body.temperature = payload.temperature; } @@ -148,12 +223,12 @@ const convertOpenAiPayloadToClaudeBody = ( } return body; -}; +} -const convertOpenAiPayloadToResponsesBody = ( +function convertOpenAiPayloadToResponsesBody( payload: ValidatedTestChatPayload, forceStream: boolean, -): Record => { +): Record { const systemContents: string[] = []; const messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; @@ -198,81 +273,282 @@ const convertOpenAiPayloadToResponsesBody = ( : 4096; return body; -}; +} -const buildUpstreamRequest = ( +function convertLegacyPayloadToEnvelope( payload: ValidatedTestChatPayload, forceStream: boolean, -): { url: string; headers: Record; body: Record } => { +): ValidatedProxyTestEnvelope { if (payload.targetFormat === 'claude') { return { - url: `http://127.0.0.1:${config.port}/v1/messages`, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': config.proxyToken, - 'anthropic-version': '2023-06-01', - }, - body: convertOpenAiPayloadToClaudeBody(payload, forceStream), + method: 'POST', + path: '/v1/messages', + requestKind: 'json', + stream: forceStream, + jobMode: false, + rawMode: false, + jsonBody: convertOpenAiPayloadToClaudeBody(payload, forceStream), }; } if (payload.targetFormat === 'responses') { return { - url: `http://127.0.0.1:${config.port}/v1/responses`, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${config.proxyToken}`, - }, - body: convertOpenAiPayloadToResponsesBody(payload, forceStream), + method: 'POST', + path: '/v1/responses', + requestKind: 'json', + stream: forceStream, + jobMode: false, + rawMode: false, + jsonBody: convertOpenAiPayloadToResponsesBody(payload, forceStream), }; } - const { targetFormat: _targetFormat, ...openAiPayload } = payload; return { - url: `http://127.0.0.1:${config.port}/v1/chat/completions`, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${config.proxyToken}`, + method: 'POST', + path: '/v1/chat/completions', + requestKind: 'json', + stream: forceStream, + jobMode: false, + rawMode: false, + jsonBody: { + ...payload, + stream: forceStream, }, - body: { ...openAiPayload, stream: forceStream }, }; -}; +} -const requestUpstreamChat = async ( - payload: ValidatedTestChatPayload, +function validateProxyEnvelope( + body: ProxyTestEnvelope, + reply: FastifyReply, +): ValidatedProxyTestEnvelope | null { + const method = body.method === 'GET' || body.method === 'DELETE' ? body.method : 'POST'; + const path = normalizeProxyPath(body.path); + + if (!path) { + reply.code(400).send({ error: { message: 'path is required', type: 'validation_error' } }); + return null; + } + + if (!isAllowedProxyPath(path)) { + reply.code(400).send({ error: { message: `path is not allowed: ${path}`, type: 'validation_error' } }); + return null; + } + + const requestKind: ProxyTestRequestKind = body.requestKind === 'multipart' + ? 'multipart' + : body.requestKind === 'empty' + ? 'empty' + : 'json'; + + if (method !== 'POST' && requestKind !== 'empty') { + reply.code(400).send({ + error: { message: `${method} only supports requestKind=empty in tester`, type: 'validation_error' }, + }); + return null; + } + + const envelope: ValidatedProxyTestEnvelope = { + method, + path, + requestKind, + stream: body.stream === true, + jobMode: body.jobMode === true, + rawMode: body.rawMode === true, + }; + + if (requestKind === 'json') { + if (typeof body.rawJsonText === 'string' && body.rawJsonText.trim().length > 0) { + envelope.rawJsonText = body.rawJsonText; + } + if (body.jsonBody !== undefined) { + envelope.jsonBody = body.jsonBody; + } + if (envelope.rawMode && typeof envelope.rawJsonText !== 'string') { + reply.code(400).send({ + error: { message: 'rawJsonText is required when rawMode is enabled', type: 'validation_error' }, + }); + return null; + } + } + + if (requestKind === 'multipart') { + envelope.multipartFields = isRecord(body.multipartFields) + ? Object.fromEntries( + Object.entries(body.multipartFields) + .filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string'), + ) + : {}; + envelope.multipartFiles = Array.isArray(body.multipartFiles) + ? body.multipartFiles + .filter((item): item is ProxyTestMultipartFile => ( + !!item + && typeof item.field === 'string' + && item.field.trim().length > 0 + && typeof item.name === 'string' + && item.name.trim().length > 0 + && typeof item.mimeType === 'string' + && item.mimeType.trim().length > 0 + && typeof item.dataUrl === 'string' + && item.dataUrl.trim().length > 0 + )) + : []; + if ((envelope.multipartFiles?.length || 0) === 0 && Object.keys(envelope.multipartFields || {}).length === 0) { + reply.code(400).send({ + error: { message: 'multipart requests require multipartFields or multipartFiles', type: 'validation_error' }, + }); + return null; + } + } + + if (requestKind === 'empty' && (method === 'POST' || path === '/v1/search')) { + // keep explicit empty body path legal, no additional validation + } + + return envelope; +} + +function decodeDataUrl(dataUrl: string): { mimeType: string; buffer: Buffer } { + const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl.trim()); + if (!match) { + throw new Error('multipartFiles[].dataUrl must be a base64 data URL'); + } + return { + mimeType: match[1] || 'application/octet-stream', + buffer: Buffer.from(match[2], 'base64'), + }; +} + +function createDefaultHeadersForPath(path: string): Record { + if (/^\/v1\/messages$/i.test(path)) { + return { + 'x-api-key': config.proxyToken, + 'anthropic-version': '2023-06-01', + }; + } + + if (/^\/(?:gemini\/[^/]+\/models\/.+|v1beta\/models\/.+)$/i.test(path)) { + return { + 'x-goog-api-key': config.proxyToken, + }; + } + + return { + Authorization: `Bearer ${config.proxyToken}`, + }; +} + +function applyStreamOverride(value: unknown, forceStream: boolean): unknown { + if (!isRecord(value)) return value; + return { + ...value, + stream: forceStream, + }; +} + +function serializeJsonEnvelopeBody( + envelope: ValidatedProxyTestEnvelope, + forceStream: boolean, +): string | undefined { + if (typeof envelope.rawJsonText === 'string' && envelope.rawJsonText.trim().length > 0) { + try { + const parsed = JSON.parse(envelope.rawJsonText); + return JSON.stringify(applyStreamOverride(parsed, forceStream)); + } catch { + return envelope.rawJsonText; + } + } + + if (envelope.jsonBody !== undefined) { + return JSON.stringify(applyStreamOverride(envelope.jsonBody, forceStream)); + } + + return JSON.stringify({ stream: forceStream }); +} + +async function buildUpstreamRequestInit( + envelope: ValidatedProxyTestEnvelope, + forceStream: boolean, +): Promise { + const headers: Record = createDefaultHeadersForPath(envelope.path); + + if (envelope.requestKind === 'json') { + headers['Content-Type'] = 'application/json'; + return { + method: envelope.method, + headers, + body: serializeJsonEnvelopeBody(envelope, forceStream), + }; + } + + if (envelope.requestKind === 'multipart') { + const formData = new FormData(); + for (const [field, value] of Object.entries(envelope.multipartFields || {})) { + formData.append(field, value); + } + for (const file of envelope.multipartFiles || []) { + const decoded = decodeDataUrl(file.dataUrl); + const bytes = Uint8Array.from(decoded.buffer); + formData.append( + file.field, + new File([bytes], file.name, { type: file.mimeType || decoded.mimeType }), + ); + } + + return { + method: envelope.method, + headers, + body: formData, + }; + } + + return { + method: envelope.method, + headers, + }; +} + +async function fetchProxyBuffered( + envelope: ValidatedProxyTestEnvelope, signal?: AbortSignal, forceStream = false, -): Promise => { - const upstreamRequest = buildUpstreamRequest(payload, forceStream); - const upstream = await fetch(upstreamRequest.url, { - method: 'POST', - headers: upstreamRequest.headers, - body: JSON.stringify(upstreamRequest.body), +): Promise { + const upstream = await fetch(`http://127.0.0.1:${config.port}${envelope.path}`, { + ...(await buildUpstreamRequestInit(envelope, forceStream)), signal, }); + const contentType = upstream.headers.get('content-type') || ''; const text = await upstream.text(); + if (!upstream.ok) { throw new UpstreamProxyError(upstream.status, normalizeErrorPayload(text)); } + if (contentType.includes('application/json')) { + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } + } + try { return JSON.parse(text); } catch { return { raw: text }; } -}; +} -const cleanupExpiredJobs = () => { +function cleanupExpiredJobs() { const now = Date.now(); for (const [jobId, job] of jobs.entries()) { if (job.expiresAt <= now) { jobs.delete(jobId); } } -}; +} -const runJob = async (jobId: string) => { +async function runJob(jobId: string) { const job = jobs.get(jobId); if (!job || job.status !== 'pending') return; @@ -280,7 +556,7 @@ const runJob = async (jobId: string) => { job.controller = controller; try { - const result = await requestUpstreamChat(job.payload, controller.signal); + const result = await fetchProxyBuffered(job.envelope, controller.signal, job.envelope.stream); const current = jobs.get(jobId); if (!current) return; current.controller = null; @@ -308,7 +584,141 @@ const runJob = async (jobId: string) => { current.updatedAt = Date.now(); current.expiresAt = current.updatedAt + JOB_TTL_MS; } -}; +} + +async function sendBufferedEnvelope( + envelope: ValidatedProxyTestEnvelope, + reply: FastifyReply, + forceStream = false, +) { + try { + const data = await fetchProxyBuffered(envelope, undefined, forceStream); + return reply.send(data); + } catch (error) { + if (error instanceof UpstreamProxyError) { + return reply.code(error.statusCode).send(error.responsePayload); + } + return reply.code(502).send({ + error: { + message: (error as any)?.message || 'proxy request failed', + type: 'server_error', + }, + }); + } +} + +async function sendStreamingEnvelope( + envelope: ValidatedProxyTestEnvelope, + request: FastifyRequest, + reply: FastifyReply, + forceStream = true, +) { + const controller = new AbortController(); + const abortUpstream = () => { + try { + if (!controller.signal.aborted) { + controller.abort(); + } + } catch { + // no-op + } + }; + + const onClientAborted = () => abortUpstream(); + const onClientClosed = () => { + if (!reply.raw.writableEnded) abortUpstream(); + }; + const cleanupClientListeners = () => { + request.raw.off?.('aborted', onClientAborted); + reply.raw.off?.('close', onClientClosed); + }; + + request.raw.on('aborted', onClientAborted); + reply.raw.on('close', onClientClosed); + + let upstream; + try { + upstream = await fetch(`http://127.0.0.1:${config.port}${envelope.path}`, { + ...(await buildUpstreamRequestInit(envelope, forceStream)), + signal: controller.signal, + }); + } catch (error) { + cleanupClientListeners(); + return reply.code(502).send({ + error: { + message: (error as any)?.message || 'proxy request failed', + type: 'server_error', + }, + }); + } + + if (!upstream.ok) { + const text = await upstream.text(); + cleanupClientListeners(); + return reply.code(upstream.status).send(normalizeErrorPayload(text)); + } + + const contentType = upstream.headers.get('content-type') || ''; + const reader = upstream.body?.getReader(); + if (!reader) { + cleanupClientListeners(); + return reply.code(502).send({ + error: { + message: 'upstream stream body missing', + type: 'server_error', + }, + }); + } + + reply.hijack(); + reply.raw.statusCode = 200; + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + + try { + if (contentType.includes('text/event-stream')) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + reply.raw.write(Buffer.from(value)); + } + } + } else { + let text = ''; + const decoder = new TextDecoder('utf-8'); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + text += decoder.decode(value, { stream: true }); + } + } + text += decoder.decode(); + reply.raw.write(`data: ${text}\n\n`); + reply.raw.write('data: [DONE]\n\n'); + } + } catch (error) { + if (!reply.raw.writableEnded) { + const message = JSON.stringify({ + error: { message: (error as any)?.message || 'stream interrupted', type: 'stream_error' }, + }); + reply.raw.write(`event: error\ndata: ${message}\n\n`); + } + } finally { + try { + await reader.cancel(); + } catch { + // no-op + } + cleanupClientListeners(); + if (!reply.raw.writableEnded) { + reply.raw.end(); + } + } +} export async function testRoutes(app: FastifyInstance) { const cleanupTimer = setInterval(cleanupExpiredJobs, JOB_CLEANUP_INTERVAL_MS); @@ -318,143 +728,126 @@ export async function testRoutes(app: FastifyInstance) { clearInterval(cleanupTimer); }); + app.post<{ Body: ProxyTestEnvelope }>( + '/api/test/proxy', + async (request, reply) => { + const envelope = validateProxyEnvelope(request.body || {}, reply); + if (!envelope) return; + return sendBufferedEnvelope(envelope, reply, false); + }, + ); + + app.post<{ Body: ProxyTestEnvelope }>( + '/api/test/proxy/stream', + async (request, reply) => { + const envelope = validateProxyEnvelope(request.body || {}, reply); + if (!envelope) return; + return sendStreamingEnvelope(envelope, request, reply, true); + }, + ); + + app.post<{ Body: ProxyTestEnvelope }>( + '/api/test/proxy/jobs', + async (request, reply) => { + const envelope = validateProxyEnvelope(request.body || {}, reply); + if (!envelope) return; + + const now = Date.now(); + const jobId = randomUUID(); + const job: TestProxyJob = { + id: jobId, + status: 'pending', + envelope, + controller: null, + createdAt: now, + updatedAt: now, + expiresAt: now + JOB_TTL_MS, + }; + + jobs.set(jobId, job); + void runJob(jobId); + + return reply.code(202).send({ + jobId, + status: job.status, + createdAt: new Date(job.createdAt).toISOString(), + expiresAt: new Date(job.expiresAt).toISOString(), + }); + }, + ); + + app.get<{ Params: { jobId: string } }>( + '/api/test/proxy/jobs/:jobId', + async (request, reply) => { + const job = jobs.get(request.params.jobId); + if (!job) { + return reply.code(404).send({ error: { message: 'job not found', type: 'not_found' } }); + } + + return reply.send({ + jobId: job.id, + status: job.status, + result: job.result, + error: job.error, + createdAt: new Date(job.createdAt).toISOString(), + updatedAt: new Date(job.updatedAt).toISOString(), + expiresAt: new Date(job.expiresAt).toISOString(), + }); + }, + ); + + app.delete<{ Params: { jobId: string } }>( + '/api/test/proxy/jobs/:jobId', + async (request, reply) => { + const job = jobs.get(request.params.jobId); + if (!job) { + return reply.code(404).send({ error: { message: 'job not found', type: 'not_found' } }); + } + + if (job.status === 'pending' && job.controller) { + try { + job.controller.abort(); + } catch { + // no-op + } + } + + jobs.delete(request.params.jobId); + return reply.send({ success: true }); + }, + ); + app.post<{ Body: TestChatRequestBody }>( '/api/test/chat', async (request, reply) => { - const body = request.body || {}; - const payload = validatePayload(body, reply); + const payload = validateLegacyPayload(request.body || {}, reply); if (!payload) return; - - try { - const data = await requestUpstreamChat(payload, undefined, false); - return reply.send(data); - } catch (error) { - if (error instanceof UpstreamProxyError) { - return reply.code(error.statusCode).send(error.responsePayload); - } - return reply.code(502).send({ - error: { - message: (error as any)?.message || 'proxy request failed', - type: 'server_error', - }, - }); - } + return sendBufferedEnvelope(convertLegacyPayloadToEnvelope(payload, false), reply, false); }, ); app.post<{ Body: TestChatRequestBody }>( '/api/test/chat/stream', async (request, reply) => { - const body = request.body || {}; - const payload = validatePayload(body, reply); + const payload = validateLegacyPayload(request.body || {}, reply); if (!payload) return; - - const controller = new AbortController(); - const abortUpstream = () => { - try { - if (!controller.signal.aborted) { - controller.abort(); - } - } catch { - // no-op - } - }; - const onClientAborted = () => { - abortUpstream(); - }; - const onClientClosed = () => { - if (!reply.raw.writableEnded) { - abortUpstream(); - } - }; - const cleanupClientListeners = () => { - request.raw.off?.('aborted', onClientAborted); - reply.raw.off?.('close', onClientClosed); - }; - request.raw.on('aborted', onClientAborted); - reply.raw.on('close', onClientClosed); - - let upstream; - try { - const upstreamRequest = buildUpstreamRequest(payload, true); - upstream = await fetch(upstreamRequest.url, { - method: 'POST', - headers: upstreamRequest.headers, - body: JSON.stringify(upstreamRequest.body), - signal: controller.signal, - }); - } catch (error) { - cleanupClientListeners(); - return reply.code(502).send({ - error: { - message: (error as any)?.message || 'proxy request failed', - type: 'server_error', - }, - }); - } - - if (!upstream.ok) { - const text = await upstream.text(); - cleanupClientListeners(); - return reply.code(upstream.status).send(normalizeErrorPayload(text)); - } - - reply.hijack(); - reply.raw.statusCode = 200; - reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); - reply.raw.setHeader('Connection', 'keep-alive'); - reply.raw.setHeader('X-Accel-Buffering', 'no'); - - const reader = upstream.body?.getReader(); - if (!reader) { - cleanupClientListeners(); - reply.raw.end(); - return; - } - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - reply.raw.write(Buffer.from(value)); - } - } - } catch (error) { - if (!reply.raw.writableEnded) { - const message = JSON.stringify({ - error: { message: (error as any)?.message || 'stream interrupted', type: 'stream_error' }, - }); - reply.raw.write(`event: error\ndata: ${message}\n\n`); - } - } finally { - try { - await reader.cancel(); - } catch { - // no-op - } - cleanupClientListeners(); - if (!reply.raw.writableEnded) { - reply.raw.end(); - } - } + return sendStreamingEnvelope(convertLegacyPayloadToEnvelope(payload, true), request, reply, true); }, ); app.post<{ Body: TestChatRequestBody }>( '/api/test/chat/jobs', async (request, reply) => { - const body = request.body || {}; - const payload = validatePayload(body, reply); + const payload = validateLegacyPayload(request.body || {}, reply); if (!payload) return; + const envelope = convertLegacyPayloadToEnvelope(payload, false); const now = Date.now(); const jobId = randomUUID(); - const job: TestChatJob = { + const job: TestProxyJob = { id: jobId, status: 'pending', - payload, + envelope, controller: null, createdAt: now, updatedAt: now, diff --git a/src/server/routes/api/tokens.route-decision-refresh-pricing.test.ts b/src/server/routes/api/tokens.route-decision-refresh-pricing.test.ts new file mode 100644 index 0000000..76bf9db --- /dev/null +++ b/src/server/routes/api/tokens.route-decision-refresh-pricing.test.ts @@ -0,0 +1,244 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, expect, it, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mkdtempSync } from 'node:fs'; + +type DbModule = typeof import('../../db/index.js'); +type TokenRouterModule = typeof import('../../services/tokenRouter.js'); +type ModelPricingServiceModule = typeof import('../../services/modelPricingService.js'); +type ConfigModule = typeof import('../../config.js'); + +const { fetchMock, withSiteProxyRequestInitMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + withSiteProxyRequestInitMock: vi.fn(), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/siteProxy.js', () => ({ + withSiteProxyRequestInit: (...args: unknown[]) => withSiteProxyRequestInitMock(...args), +})); + +function buildPricingPayload(modelName: string, modelRatio: number) { + return { + data: [{ + model_name: modelName, + quota_type: 0, + model_ratio: modelRatio, + completion_ratio: 1, + enable_groups: ['default'], + }], + group_ratio: { default: 1 }, + }; +} + +describe('POST /api/routes/decision/batch refreshPricingCatalog', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; + let fetchModelPricingCatalog: ModelPricingServiceModule['fetchModelPricingCatalog']; + let config: ConfigModule['config']; + let originalRoutingWeights: typeof config.routingWeights; + let originalRoutingFallbackUnitCost: number; + let dataDir = ''; + let pricingPhase: 'old' | 'new' = 'old'; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-decision-refresh-pricing-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./tokens.js'); + const tokenRouterModule = await import('../../services/tokenRouter.js'); + const modelPricingServiceModule = await import('../../services/modelPricingService.js'); + const configModule = await import('../../config.js'); + db = dbModule.db; + schema = dbModule.schema; + invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; + fetchModelPricingCatalog = modelPricingServiceModule.fetchModelPricingCatalog; + config = configModule.config; + originalRoutingWeights = { ...config.routingWeights }; + originalRoutingFallbackUnitCost = config.routingFallbackUnitCost; + + app = Fastify(); + await app.register(routesModule.tokensRoutes); + }); + + beforeEach(async () => { + pricingPhase = 'old'; + fetchMock.mockReset(); + withSiteProxyRequestInitMock.mockReset(); + withSiteProxyRequestInitMock.mockImplementation(async (_url: string, init: Record) => init); + fetchMock.mockImplementation(async (url: string | URL) => { + const normalizedUrl = String(url); + let payload: ReturnType | null = null; + if (normalizedUrl.includes('pricing-a.example.com')) { + payload = buildPricingPayload('gpt-4o-mini', pricingPhase === 'old' ? 0.1 : 10); + } else if (normalizedUrl.includes('pricing-b.example.com')) { + payload = buildPricingPayload('gpt-4o-mini', pricingPhase === 'old' ? 10 : 0.1); + } + + if (!payload) { + return new Response(JSON.stringify({ data: [] }), { + status: 404, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + } + + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + }); + + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + invalidateTokenRouterCache(); + + config.routingWeights = { + baseWeightFactor: 0.35, + valueScoreFactor: 0.65, + costWeight: 1, + balanceWeight: 0, + usageWeight: 0, + }; + config.routingFallbackUnitCost = 100; + }); + + afterAll(async () => { + await app.close(); + config.routingWeights = { ...originalRoutingWeights }; + config.routingFallbackUnitCost = originalRoutingFallbackUnitCost; + invalidateTokenRouterCache(); + delete process.env.DATA_DIR; + }); + + it('forces pricing catalog refresh before recomputing exact-route probabilities', async () => { + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const siteA = await db.insert(schema.sites).values({ + name: 'site-a', + url: 'https://pricing-a.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const siteB = await db.insert(schema.sites).values({ + name: 'site-b', + url: 'https://pricing-b.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const accountA = await db.insert(schema.accounts).values({ + siteId: siteA.id, + username: 'user-a', + accessToken: 'access-a', + apiToken: 'api-a', + status: 'active', + }).returning().get(); + + const accountB = await db.insert(schema.accounts).values({ + siteId: siteB.id, + username: 'user-b', + accessToken: 'access-b', + apiToken: 'api-b', + status: 'active', + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: route.id, + accountId: accountA.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }, + { + routeId: route.id, + accountId: accountB.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }, + ]).run(); + + await fetchModelPricingCatalog({ + site: { + id: siteA.id, + url: siteA.url, + platform: siteA.platform, + apiKey: siteA.apiKey, + }, + account: { + id: accountA.id, + accessToken: accountA.accessToken, + apiToken: accountA.apiToken, + }, + modelName: 'gpt-4o-mini', + }); + await fetchModelPricingCatalog({ + site: { + id: siteB.id, + url: siteB.url, + platform: siteB.platform, + apiKey: siteB.apiKey, + }, + account: { + id: accountB.id, + accessToken: accountB.accessToken, + apiToken: accountB.apiToken, + }, + modelName: 'gpt-4o-mini', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + + pricingPhase = 'new'; + + const response = await app.inject({ + method: 'POST', + url: '/api/routes/decision/batch', + payload: { + models: ['gpt-4o-mini'], + refreshPricingCatalog: true, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + success: boolean; + decisions: Record; + }>; + }; + expect(body.success).toBe(true); + + const decision = body.decisions['gpt-4o-mini']; + expect(decision?.matched).toBe(true); + + const candidateA = decision?.candidates.find((candidate) => candidate.siteName === 'site-a'); + const candidateB = decision?.candidates.find((candidate) => candidate.siteName === 'site-b'); + + expect(candidateA).toBeTruthy(); + expect(candidateB).toBeTruthy(); + expect(candidateB?.probability || 0).toBeGreaterThan(candidateA?.probability || 0); + expect(candidateA?.reason || '').toContain('成本=目录'); + expect(candidateB?.reason || '').toContain('成本=目录'); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/server/routes/api/tokens.route-decision-snapshot.test.ts b/src/server/routes/api/tokens.route-decision-snapshot.test.ts new file mode 100644 index 0000000..89f098f --- /dev/null +++ b/src/server/routes/api/tokens.route-decision-snapshot.test.ts @@ -0,0 +1,179 @@ +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'); +type TokenRouterModule = typeof import('../../services/tokenRouter.js'); + +describe('route decision snapshots', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-decision-snapshot-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./tokens.js'); + const tokenRouterModule = await import('../../services/tokenRouter.js'); + db = dbModule.db; + schema = dbModule.schema; + invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; + + app = Fastify(); + await app.register(routesModule.tokensRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + await db.delete(schema.settings).run(); + invalidateTokenRouterCache(); + }); + + afterAll(async () => { + await app.close(); + invalidateTokenRouterCache(); + delete process.env.DATA_DIR; + }); + + it('persists exact-route decision snapshots and exposes them from getRoutes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'snapshot-site', + url: 'https://snapshot-site.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'snapshot-user', + accessToken: 'snapshot-access', + apiToken: 'snapshot-api', + status: 'active', + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }).run(); + + const refreshResponse = await app.inject({ + method: 'POST', + url: '/api/routes/decision/batch', + payload: { + models: ['gpt-4o-mini'], + persistSnapshots: true, + }, + }); + + expect(refreshResponse.statusCode).toBe(200); + + const routesResponse = await app.inject({ + method: 'GET', + url: '/api/routes', + }); + + expect(routesResponse.statusCode).toBe(200); + const body = routesResponse.json() as Array<{ + id: number; + decisionSnapshot?: { matched: boolean; candidates: Array } | null; + decisionRefreshedAt?: string | null; + }>; + const refreshedRoute = body.find((item) => item.id === route.id); + + expect(refreshedRoute?.decisionSnapshot?.matched).toBe(true); + expect(Array.isArray(refreshedRoute?.decisionSnapshot?.candidates)).toBe(true); + expect(refreshedRoute?.decisionSnapshot?.candidates.length).toBeGreaterThan(0); + expect(typeof refreshedRoute?.decisionRefreshedAt).toBe('string'); + }); + + it('persists wildcard route-wide decision snapshots and exposes them from getRoutes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'wildcard-snapshot-site', + url: 'https://wildcard-snapshot-site.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'wildcard-snapshot-user', + accessToken: 'wildcard-snapshot-access', + apiToken: 'wildcard-snapshot-api', + status: 'active', + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 're:^claude-(opus|sonnet)-4-6$', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: route.id, + accountId: account.id, + tokenId: null, + sourceModel: 'claude-opus-4-6', + priority: 0, + weight: 10, + enabled: true, + }, + { + routeId: route.id, + accountId: account.id, + tokenId: null, + sourceModel: 'claude-sonnet-4-6', + priority: 0, + weight: 10, + enabled: true, + }, + ]).run(); + + const refreshResponse = await app.inject({ + method: 'POST', + url: '/api/routes/decision/route-wide/batch', + payload: { + routeIds: [route.id], + persistSnapshots: true, + }, + }); + + expect(refreshResponse.statusCode).toBe(200); + + const routesResponse = await app.inject({ + method: 'GET', + url: '/api/routes', + }); + + expect(routesResponse.statusCode).toBe(200); + const body = routesResponse.json() as Array<{ + id: number; + decisionSnapshot?: { matched: boolean; routeId?: number } | null; + decisionRefreshedAt?: string | null; + }>; + const refreshedRoute = body.find((item) => item.id === route.id); + + expect(refreshedRoute?.decisionSnapshot?.matched).toBe(true); + expect(refreshedRoute?.decisionSnapshot?.routeId).toBe(route.id); + expect(typeof refreshedRoute?.decisionRefreshedAt).toBe('string'); + }); +}); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 7811ecb..18f6ba0 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1,9 +1,15 @@ import { FastifyInstance } from 'fastify'; -import { and, eq, inArray } from 'drizzle-orm'; -import { db, schema } from '../../db/index.js'; -import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; -import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; +import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { + clearRouteDecisionSnapshot, + clearRouteDecisionSnapshots, + parseRouteDecisionSnapshot, + saveRouteDecisionSnapshots, +} from '../../services/routeDecisionSnapshotStore.js'; function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); @@ -198,20 +204,26 @@ type BatchChannelPriorityUpdate = { priority: number; }; -type BatchRouteDecisionModels = { - models: string[]; -}; - -type BatchRouteDecisionRouteModels = { - items: Array<{ - routeId: number; - model: string; - }>; -}; - -type BatchRouteWideDecisionRouteIds = { - routeIds: number[]; -}; +type BatchRouteDecisionModels = { + models: string[]; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +type BatchRouteDecisionRouteModels = { + items: Array<{ + routeId: number; + model: string; + }>; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +type BatchRouteWideDecisionRouteIds = { + routeIds: number[]; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } { if (!input || typeof input !== 'object') { @@ -252,7 +264,9 @@ function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchCha return { ok: true, updates: normalized }; } -function parseBatchRouteDecisionModels(input: unknown): { ok: true; models: string[] } | { ok: false; message: string } { +function parseBatchRouteDecisionModels( + input: unknown, +): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -277,12 +291,17 @@ function parseBatchRouteDecisionModels(input: unknown): { ok: true; models: stri return { ok: false, message: 'models 中没有有效模型名称' }; } - return { ok: true, models: normalized }; -} - -function parseBatchRouteDecisionRouteModels( - input: unknown, -): { ok: true; items: Array<{ routeId: number; model: string }> } | { ok: false; message: string } { + return { + ok: true, + models: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +function parseBatchRouteDecisionRouteModels( + input: unknown, +): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -316,12 +335,17 @@ function parseBatchRouteDecisionRouteModels( return { ok: false, message: 'items 中没有有效 routeId/model' }; } - return { ok: true, items: normalized }; -} - -function parseBatchRouteWideDecisionRouteIds( - input: unknown, -): { ok: true; routeIds: number[] } | { ok: false; message: string } { + return { + ok: true, + items: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +function parseBatchRouteWideDecisionRouteIds( + input: unknown, +): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -346,14 +370,19 @@ function parseBatchRouteWideDecisionRouteIds( return { ok: false, message: 'routeIds 中没有有效 routeId' }; } - return { ok: true, routeIds: normalized }; -} + return { + ok: true, + routeIds: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} export async function tokensRoutes(app: FastifyInstance) { // List all routes - app.get('/api/routes', async () => { - const routes = await db.select().from(schema.tokenRoutes).all(); - if (routes.length === 0) return []; + app.get('/api/routes', async () => { + const routes = await db.select().from(schema.tokenRoutes).all(); + if (routes.length === 0) return []; const routeIds = routes.map((route) => route.id); const channelRows = await db.select().from(schema.routeChannels) @@ -384,11 +413,13 @@ export async function tokensRoutes(app: FastifyInstance) { }); } - return routes.map((route) => ({ - ...route, - channels: channelsByRoute.get(route.id) || [], - })); - }); + return routes.map((route) => ({ + ...route, + decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), + decisionRefreshedAt: route.decisionRefreshedAt ?? null, + channels: channelsByRoute.get(route.id) || [], + })); + }); app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => { const model = (request.query.model || '').trim(); @@ -400,49 +431,94 @@ export async function tokensRoutes(app: FastifyInstance) { return { success: true, decision }; }); - app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { - const parsed = parseBatchRouteDecisionModels(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - + app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { + const parsed = parseBatchRouteDecisionModels(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } + const decisions: Record>> = {}; + const routes = parsed.persistSnapshots + ? await db.select({ + id: schema.tokenRoutes.id, + modelPattern: schema.tokenRoutes.modelPattern, + }).from(schema.tokenRoutes).all() + : []; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; for (const model of parsed.models) { + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys }); + } decisions[model] = await tokenRouter.explainSelection(model); } + + if (parsed.persistSnapshots) { + const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = []; + for (const model of parsed.models) { + const decision = decisions[model]; + for (const route of routes) { + if (!isExactModelPattern(route.modelPattern)) continue; + if (!matchesModelPattern(model, route.modelPattern)) continue; + snapshotWrites.push({ routeId: route.id, snapshot: decision }); + } + } + await saveRouteDecisionSnapshots(snapshotWrites); + } + + return { success: true, decisions }; + }); - 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 }); - } - + 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>>> = {}; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; for (const item of parsed.items) { const routeKey = String(item.routeId); if (!decisions[routeKey]) decisions[routeKey] = {}; + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys }); + } decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model); } + + if (parsed.persistSnapshots) { + await saveRouteDecisionSnapshots(parsed.items.map((item) => ({ + routeId: item.routeId, + snapshot: decisions[String(item.routeId)]?.[item.model] ?? null, + }))); + } + + return { success: true, decisions }; + }); - 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 }); - } - + 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>> = {}; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; for (const routeId of parsed.routeIds) { + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys }); + } decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId); } - - return { success: true, decisions }; - }); + + if (parsed.persistSnapshots) { + await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({ + routeId, + snapshot: decisions[String(routeId)] ?? null, + }))); + } + + return { success: true, decisions }; + }); // Create a route app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => { @@ -492,9 +568,13 @@ export async function tokensRoutes(app: FastifyInstance) { await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; + const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined; if (modelPatternChanged) { await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); } + if (routeBehaviorChanged) { + await clearRouteDecisionSnapshot(id); + } invalidateTokenRouterCache(); return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); }); @@ -558,9 +638,10 @@ export async function tokensRoutes(app: FastifyInstance) { if (!created) { return reply.code(500).send({ success: false, message: '创建通道失败' }); } - invalidateTokenRouterCache(); - return created; - }); + await clearRouteDecisionSnapshot(routeId); + invalidateTokenRouterCache(); + return created; + }); // Batch update channel priorities app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => { @@ -586,12 +667,13 @@ export async function tokensRoutes(app: FastifyInstance) { }).where(eq(schema.routeChannels.id, update.id)).run(); } - const updatedChannels = await db.select().from(schema.routeChannels) - .where(inArray(schema.routeChannels.id, channelIds)) - .all(); - invalidateTokenRouterCache(); - return { success: true, channels: updatedChannels }; - }); + const updatedChannels = await db.select().from(schema.routeChannels) + .where(inArray(schema.routeChannels.id, channelIds)) + .all(); + await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); + invalidateTokenRouterCache(); + return { success: true, channels: updatedChannels }; + }); // Update a channel app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => { @@ -632,19 +714,24 @@ export async function tokensRoutes(app: FastifyInstance) { for (const key of ['priority', 'weight', 'enabled', 'tokenId']) { if (body[key] !== undefined) updates[key] = body[key]; } - - await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); - invalidateTokenRouterCache(); - return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - }); - - // Delete a channel - app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { - const channelId = parseInt(request.params.channelId, 10); - await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); - invalidateTokenRouterCache(); - return { success: true }; - }); + + await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); + await clearRouteDecisionSnapshot(channel.routeId); + invalidateTokenRouterCache(); + return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + }); + + // Delete a channel + app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { + const channelId = parseInt(request.params.channelId, 10); + const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); + if (channel) { + await clearRouteDecisionSnapshot(channel.routeId); + } + invalidateTokenRouterCache(); + return { success: true }; + }); // Rebuild routes/channels from model availability. app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { diff --git a/src/server/routes/proxy/architecture-boundaries.test.ts b/src/server/routes/proxy/architecture-boundaries.test.ts new file mode 100644 index 0000000..9043f63 --- /dev/null +++ b/src/server/routes/proxy/architecture-boundaries.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('proxy route architecture boundaries', () => { + it('keeps shared protocol helpers out of chat route', () => { + const source = readSource('./chat.ts'); + expect(source).not.toContain("from './chatFormats.js'"); + expect(source).toContain("from '../../transformers/openai/chat/index.js'"); + expect(source).toContain("from '../../transformers/anthropic/messages/index.js'"); + }); + it('keeps anthropic-specific stream orchestration out of chat route', () => { + const source = readSource('./chat.ts'); + expect(source).not.toContain('function syncClaudeStreamStateFromRawEvent('); + expect(source).not.toContain('function serializeRawSseEvent('); + expect(source).not.toContain('function isClaudeSseEventName('); + }); + + it('keeps responses protocol assembly out of responses route', () => { + const source = readSource('./responses.ts'); + expect(source).not.toContain('function toResponsesPayload('); + expect(source).not.toContain('function createResponsesStreamState('); + expect(source).not.toContain('function convertResponsesBodyToOpenAiBody('); + }); + + it('removes normalizeContentText from upstream endpoint routing', () => { + const source = readSource('./upstreamEndpoint.ts'); + expect(source).not.toContain('function normalizeContentText('); + expect(source).not.toContain('normalizeContentText('); + }); + + it('keeps gemini runtime closure in transformer-owned helpers', () => { + const source = readSource('./gemini.ts'); + expect(source).toContain('serializeAggregateResponse'); + expect(source).toContain('parseSsePayloads'); + expect(source).toContain('aggregator.apply(aggregateState, parsed)'); + }); +}); diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 967f3b2..19172b0 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -9,19 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { - type DownstreamFormat, - createStreamTransformContext, - createClaudeDownstreamContext, - parseDownstreamChatRequest, - pullSseEventsWithDone, - normalizeUpstreamStreamEvent, - serializeNormalizedStreamEvent, - serializeStreamDone, - normalizeUpstreamFinalResponse, - serializeFinalResponse, - buildSyntheticOpenAiChunks, -} from './chatFormats.js'; +import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { buildMinimalJsonHeadersForCompatibility, buildUpstreamEndpointRequest, @@ -38,19 +26,16 @@ import { composeProxyLogMessage } from './logPathMeta.js'; import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; +import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; +import { + anthropicMessagesTransformer, + isAnthropicRawSseEventName, + serializeAnthropicFinalAsStream, + serializeAnthropicRawSseEvent, + syncAnthropicRawStreamStateFromEvent, +} from '../../transformers/anthropic/messages/index.js'; const MAX_RETRIES = 2; -const CLAUDE_SSE_EVENT_NAMES = new Set([ - 'message_start', - 'content_block_start', - 'content_block_delta', - 'content_block_stop', - 'message_delta', - 'message_stop', - 'ping', - 'error', -]); - function shouldRetryClaudeMessagesWithNormalizedBody( downstreamFormat: DownstreamFormat, endpointPath: string, @@ -71,53 +56,6 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; } -function isClaudeSseEventName(value: unknown): value is string { - return typeof value === 'string' && CLAUDE_SSE_EVENT_NAMES.has(value); -} - -function serializeRawSseEvent(event: string, data: string): string { - const dataLines = data.split('\n').map((line) => `data: ${line}`).join('\n'); - if (event) { - return `event: ${event}\n${dataLines}\n\n`; - } - return `${dataLines}\n\n`; -} - -function syncClaudeStreamStateFromRawEvent( - eventName: string, - parsedPayload: unknown, - streamContext: { id: string; model: string }, - claudeContext: { messageStarted: boolean; contentBlockStarted: boolean; doneSent: boolean }, -) { - if (eventName === 'message_start') { - claudeContext.messageStarted = true; - if (isRecord(parsedPayload) && isRecord(parsedPayload.message)) { - const message = parsedPayload.message; - if (typeof message.id === 'string' && message.id.trim().length > 0) { - streamContext.id = message.id; - } - if (typeof message.model === 'string' && message.model.trim().length > 0) { - streamContext.model = message.model; - } - } - return; - } - - if (eventName === 'content_block_start') { - claudeContext.contentBlockStarted = true; - return; - } - - if (eventName === 'content_block_stop') { - claudeContext.contentBlockStarted = false; - return; - } - - if (eventName === 'message_stop') { - claudeContext.doneSent = true; - } -} - export async function chatProxyRoute(app: FastifyInstance) { app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => handleChatProxyRequest(request, reply, 'openai')); @@ -133,7 +71,10 @@ async function handleChatProxyRequest( reply: FastifyReply, downstreamFormat: DownstreamFormat, ) { - const parsedRequest = parseDownstreamChatRequest(request.body, downstreamFormat); + const downstreamTransformer = downstreamFormat === 'claude' + ? anthropicMessagesTransformer + : openAiChatTransformer; + const parsedRequest = downstreamTransformer.transformRequest(request.body); if (parsedRequest.error) { return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload); } @@ -345,8 +286,8 @@ async function handleChatProxyRequest( reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('X-Accel-Buffering', 'no'); - const streamContext = createStreamTransformContext(modelName); - const claudeContext = createClaudeDownstreamContext(); + const streamContext = downstreamTransformer.createStreamContext(modelName); + const claudeContext = anthropicMessagesTransformer.createDownstreamContext(); let parsedUsage: ReturnType = { promptTokens: 0, completionTokens: 0, @@ -363,38 +304,24 @@ async function handleChatProxyRequest( }; const writeDone = () => { - writeLines(serializeStreamDone(downstreamFormat, streamContext, claudeContext)); + writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext)); }; const emitNormalizedFinalAsStream = (upstreamData: unknown, fallbackText = '') => { - const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, modelName, fallbackText); + const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, fallbackText); streamContext.id = normalizedFinal.id; streamContext.model = normalizedFinal.model; streamContext.created = normalizedFinal.created; if (downstreamFormat === 'openai') { - const syntheticChunks = buildSyntheticOpenAiChunks(normalizedFinal); + const syntheticChunks = openAiChatTransformer.buildSyntheticChunks(normalizedFinal); for (const chunk of syntheticChunks) { reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`); } return; } - writeLines(serializeNormalizedStreamEvent('claude', { role: 'assistant' }, streamContext, claudeContext)); - - const combinedText = [normalizedFinal.reasoningContent, normalizedFinal.content] - .filter((item) => typeof item === 'string' && item.trim().length > 0) - .join('\n\n'); - - if (combinedText) { - writeLines(serializeNormalizedStreamEvent('claude', { - contentDelta: combinedText, - }, streamContext, claudeContext)); - } - - writeLines(serializeNormalizedStreamEvent('claude', { - finishReason: normalizedFinal.finishReason, - }, streamContext, claudeContext)); + writeLines(serializeAnthropicFinalAsStream(normalizedFinal, streamContext, claudeContext)); }; const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); @@ -470,7 +397,7 @@ async function handleChatProxyRequest( let shouldTerminateEarly = false; const consumeSseBuffer = (incoming: string): string => { - const pulled = pullSseEventsWithDone(incoming); + const pulled = downstreamTransformer.pullSseEvents(incoming); for (const eventBlock of pulled.events) { if (eventBlock.data === '[DONE]') { writeDone(); @@ -487,23 +414,22 @@ async function handleChatProxyRequest( if (parsedPayload && typeof parsedPayload === 'object') { parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload)); - if (downstreamFormat === 'claude') { const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string') ? parsedPayload.type : ''; - const claudeEventName = isClaudeSseEventName(eventBlock.event) + const claudeEventName = isAnthropicRawSseEventName(eventBlock.event) ? eventBlock.event - : (isClaudeSseEventName(payloadType) ? payloadType : ''); + : (isAnthropicRawSseEventName(payloadType) ? payloadType : ''); if (claudeEventName) { - syncClaudeStreamStateFromRawEvent( + syncAnthropicRawStreamStateFromEvent( claudeEventName, parsedPayload, streamContext, claudeContext, ); - reply.raw.write(serializeRawSseEvent(claudeEventName, eventBlock.data)); + reply.raw.write(serializeAnthropicRawSseEvent(claudeEventName, eventBlock.data)); if (claudeContext.doneSent) { shouldTerminateEarly = true; break; @@ -511,19 +437,13 @@ async function handleChatProxyRequest( continue; } } - - const normalizedEvent = normalizeUpstreamStreamEvent(parsedPayload, streamContext, modelName); - writeLines(serializeNormalizedStreamEvent( - downstreamFormat, - normalizedEvent, - streamContext, - claudeContext, - )); + const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName); + writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext)); if (downstreamFormat === 'claude' && claudeContext.doneSent) { shouldTerminateEarly = true; break; } - if (downstreamFormat === 'openai' && streamContext.doneSent) { + if (streamContext.doneSent) { shouldTerminateEarly = true; break; } @@ -533,7 +453,7 @@ async function handleChatProxyRequest( if (downstreamFormat === 'openai') { reply.raw.write(`data: ${eventBlock.data}\n\n`); } else { - writeLines(serializeNormalizedStreamEvent('claude', { + writeLines(anthropicMessagesTransformer.serializeStreamEvent({ contentDelta: eventBlock.data, }, streamContext, claudeContext)); if (claudeContext.doneSent) { @@ -628,8 +548,8 @@ async function handleChatProxyRequest( const latency = Date.now() - startTime; const parsedUsage = parseProxyUsage(upstreamData); - const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, modelName, rawText); - const downstreamResponse = serializeFinalResponse(downstreamFormat, normalizedFinal, parsedUsage); + const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText); + const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage); const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, diff --git a/src/server/routes/proxy/chatFormats.ts b/src/server/routes/proxy/chatFormats.ts index 61b22b8..50f42fa 100644 --- a/src/server/routes/proxy/chatFormats.ts +++ b/src/server/routes/proxy/chatFormats.ts @@ -1,1541 +1,21 @@ -export type DownstreamFormat = 'openai' | 'claude'; - -export type ParsedSseEvent = { - event: string; - data: string; -}; - -export type StreamTransformContext = { - id: string; - model: string; - created: number; - roleSent: boolean; - doneSent: boolean; - toolCalls: Record; -}; - -export type ClaudeDownstreamContext = { - messageStarted: boolean; - contentBlockStarted: boolean; - doneSent: boolean; - textBlockIndex: number | null; - nextContentBlockIndex: number; - toolBlocks: Record; -}; - -export type NormalizedStreamEvent = { - role?: 'assistant'; - contentDelta?: string; - reasoningDelta?: string; - toolCallDeltas?: Array<{ - index: number; - id?: string; - name?: string; - argumentsDelta?: string; - }>; - finishReason?: string | null; - done?: boolean; -}; - -export type NormalizedFinalResponse = { - id: string; - model: string; - created: number; - content: string; - reasoningContent: string; - finishReason: string; - toolCalls: Array<{ - id: string; - name: string; - arguments: string; - }>; -}; - -export type ParsedDownstreamChatRequest = { - requestedModel: string; - isStream: boolean; - upstreamBody: Record; - claudeOriginalBody?: Record; -}; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; -} - -function pickFiniteNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -function ensureIntegerTimestamp(value: unknown, fallback: number): number { - const n = Number(value); - if (!Number.isFinite(n) || n <= 0) return fallback; - return Math.trunc(n); -} - -function joinNonEmpty(parts: string[]): string { - return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n'); -} - -function textFromPart(part: unknown): string { - if (typeof part === 'string') return part; - if (!isRecord(part)) return ''; - - if (typeof part.text === 'string') return part.text; - if (typeof part.content === 'string') return part.content; - if (typeof part.output_text === 'string') return part.output_text; - if (typeof part.completion === 'string') return part.completion; - if (typeof part.partial_json === 'string') return part.partial_json; - if (typeof part.reasoning_content === 'string') return part.reasoning_content; - if (typeof part.reasoning === 'string') return part.reasoning; - - if (Array.isArray(part.content)) { - return part.content.map((item) => textFromPart(item)).join(''); - } - - if (isRecord(part.delta)) { - const fromDelta = textFromPart(part.delta); - if (fromDelta) return fromDelta; - } - - return ''; -} - -function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } { - if (typeof value === 'string') return { content: value, reasoning: '' }; - if (Array.isArray(value)) { - const contentParts: string[] = []; - const reasoningParts: string[] = []; - for (const item of value) { - if (typeof item === 'string') { - contentParts.push(item); - continue; - } - if (!isRecord(item)) continue; - const type = typeof item.type === 'string' ? item.type : ''; - - if (type === 'thinking' && typeof item.thinking === 'string') { - reasoningParts.push(item.thinking); - continue; - } - if (type === 'thinking_delta' && typeof item.text === 'string') { - reasoningParts.push(item.text); - continue; - } - if (typeof item.thought === 'boolean' && item.thought && typeof item.text === 'string') { - reasoningParts.push(item.text); - continue; - } - - const text = textFromPart(item); - if (text) contentParts.push(text); - } - - return { - content: contentParts.join(''), - reasoning: reasoningParts.join(''), - }; - } - - if (!isRecord(value)) return { content: '', reasoning: '' }; - - if (Array.isArray(value.parts)) { - return extractTextAndReasoning(value.parts); - } - - return { - content: textFromPart(value), - reasoning: '', - }; -} - -export function normalizeStopReason(raw: unknown): string | null { - const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; - if (!value) return null; - - if (value === 'failed' || value === 'error') { - return 'error'; - } - - if ( - value === 'end_turn' - || value === 'stop' - || value === 'end' - || value === 'eos' - || value === 'finished' - || value === 'completed' - || value === 'stop_sequence' - ) { - return 'stop'; - } - - if ( - value === 'incomplete' - || value === 'max_tokens' - || value === 'length' - || value === 'max_output_tokens' - || value === 'max_tokens_exceeded' - || value.includes('max') - ) { - return 'length'; - } - - if (value === 'tool_use' || value === 'tool_calls' || value.includes('tool')) { - return 'tool_calls'; - } - - return null; -} - -export function toClaudeStopReason(finishReason: string | null | undefined): string { - const value = normalizeStopReason(finishReason); - if (value === 'length') return 'max_tokens'; - if (value === 'tool_calls') return 'tool_use'; - return 'end_turn'; -} - -export function createStreamTransformContext(modelName: string): StreamTransformContext { - return { - id: `chatcmpl-meta-${Date.now()}`, - model: modelName, - created: Math.floor(Date.now() / 1000), - roleSent: false, - doneSent: false, - toolCalls: {}, - }; -} - -export function createClaudeDownstreamContext(): ClaudeDownstreamContext { - return { - messageStarted: false, - contentBlockStarted: false, - doneSent: false, - textBlockIndex: null, - nextContentBlockIndex: 0, - toolBlocks: {}, - }; -} - -function buildClaudeMessageId(sourceId: string): string { - if (sourceId.startsWith('msg_')) return sourceId; - const sanitized = sourceId.replace(/[^A-Za-z0-9_-]/g, '_'); - return `msg_${sanitized || Date.now()}`; -} - -function serializeSse(event: string, data: unknown): string { - const payload = typeof data === 'string' ? data : JSON.stringify(data); - if (event) { - return `event: ${event}\ndata: ${payload}\n\n`; - } - return `data: ${payload}\n\n`; -} - -function extractAssistantContent(choice: any): string { - const messageContent = choice?.message?.content; - const parsedMessage = extractTextAndReasoning(messageContent).content; - if (parsedMessage) return parsedMessage; - - const content = extractTextAndReasoning(choice?.content).content; - if (content) return content; - - if (typeof choice?.text === 'string' && choice.text.length > 0) return choice.text; - if (typeof choice?.completion === 'string' && choice.completion.length > 0) return choice.completion; - if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) return choice.output_text; - if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) return choice.delta.content; - - return ''; -} - -function extractAssistantReasoning(choice: any): string { - const message = choice?.message || {}; - const direct = [ - message.reasoning_content, - message.reasoning, - choice?.reasoning_content, - choice?.reasoning, - ].find((item) => typeof item === 'string' && item.length > 0); - - if (typeof direct === 'string') return direct; - - const parsed = extractTextAndReasoning(message.content); - if (parsed.reasoning) return parsed.reasoning; - - const nested = extractTextAndReasoning(choice?.content).reasoning; - if (nested) return nested; - - return ''; -} - -function parseClaudeMessageContent(content: unknown): string { - return extractTextAndReasoning(content).content; -} - -function parseResponsesOutputText(payload: Record): string { - const direct = typeof payload.output_text === 'string' ? payload.output_text : ''; - if (direct) return direct; - - const output = Array.isArray(payload.output) ? payload.output : []; - const parts: string[] = []; - for (const item of output) { - if (!isRecord(item)) continue; - const parsed = extractTextAndReasoning(item.content ?? item); - if (parsed.content) parts.push(parsed.content); - } - - return parts.join('\n\n'); -} - -function stringifyUnknownValue(value: unknown): string { - if (typeof value === 'string') return value; - if (value === undefined || value === null) return ''; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value) || isRecord(value)) { - try { - return JSON.stringify(value); - } catch { - return ''; - } - } - return ''; -} - -function parseJsonLike(value: string): unknown { - const trimmed = value.trim(); - if (!trimmed) return {}; - try { - return JSON.parse(trimmed); - } catch { - return { value }; - } -} - -function collectToolCallsFromOpenAiChoice(choice: any): Array<{ id: string; name: string; arguments: string }> { - const message = isRecord(choice?.message) ? choice.message : {}; - const rawToolCalls = Array.isArray((message as any).tool_calls) - ? (message as any).tool_calls - : (Array.isArray(choice?.tool_calls) ? choice.tool_calls : []); - - const toolCalls: Array<{ id: string; name: string; arguments: string }> = []; - for (let index = 0; index < rawToolCalls.length; index += 1) { - const rawToolCall = rawToolCalls[index]; - if (!isRecord(rawToolCall)) continue; - const fn = isRecord(rawToolCall.function) ? rawToolCall.function : {}; - const id = ( - typeof rawToolCall.id === 'string' && rawToolCall.id.trim().length > 0 - ? rawToolCall.id.trim() - : `call_${index}` - ); - const name = ( - typeof fn.name === 'string' && fn.name.trim().length > 0 - ? fn.name.trim() - : (typeof rawToolCall.name === 'string' ? rawToolCall.name.trim() : '') - ); - const argumentsText = ( - typeof fn.arguments === 'string' - ? fn.arguments - : stringifyUnknownValue(fn.arguments ?? rawToolCall.arguments) - ); - toolCalls.push({ - id, - name, - arguments: argumentsText, - }); - } - - return toolCalls; -} - -function collectToolCallsFromClaudeContent(content: unknown): Array<{ id: string; name: string; arguments: string }> { - const contentItems = Array.isArray(content) ? content : []; - const toolCalls: Array<{ id: string; name: string; arguments: string }> = []; - - for (let index = 0; index < contentItems.length; index += 1) { - const block = contentItems[index]; - if (!isRecord(block)) continue; - if (block.type !== 'tool_use') continue; - - const id = ( - typeof block.id === 'string' && block.id.trim().length > 0 - ? block.id.trim() - : `toolu_${index}` - ); - const name = typeof block.name === 'string' ? block.name.trim() : ''; - const argumentsText = stringifyUnknownValue(block.input); - toolCalls.push({ - id, - name, - arguments: argumentsText, - }); - } - - return toolCalls; -} - -function collectToolCallsFromResponsesPayload(payload: Record): Array<{ id: string; name: string; arguments: string }> { - const output = Array.isArray(payload.output) ? payload.output : []; - const toolCalls: Array<{ id: string; name: string; arguments: string }> = []; - - for (let index = 0; index < output.length; index += 1) { - const item = output[index]; - if (!isRecord(item)) continue; - if (item.type !== 'function_call') continue; - - const id = ( - typeof item.call_id === 'string' && item.call_id.trim().length > 0 - ? item.call_id.trim() - : (typeof item.id === 'string' && item.id.trim().length > 0 ? item.id.trim() : `call_${index}`) - ); - const name = typeof item.name === 'string' ? item.name.trim() : ''; - const argumentsText = ( - typeof item.arguments === 'string' - ? item.arguments - : stringifyUnknownValue(item.arguments) - ); - toolCalls.push({ - id, - name, - arguments: argumentsText, - }); - } - - return toolCalls; -} - -function convertClaudeRequestToOpenAiBody(body: Record): { - model: string; - stream: boolean; - messages: Array>; - payload: Record; -} { - const model = typeof body.model === 'string' ? body.model.trim() : ''; - const stream = body.stream === true; - - const messages: Array> = []; - - const appendMessage = (role: string, content: unknown) => { - const text = parseClaudeMessageContent(content); - if (!text) return; - messages.push({ role, content: text }); - }; - - const appendToolResultMessage = (toolUseId: unknown, content: unknown) => { - const toolCallId = typeof toolUseId === 'string' ? toolUseId.trim() : ''; - if (!toolCallId) return; - - const text = ( - parseClaudeMessageContent(content) - || stringifyUnknownValue(content) - ).trim(); - if (!text) return; - - messages.push({ - role: 'tool', - tool_call_id: toolCallId, - content: text, - }); - }; - - const system = body.system; - if (typeof system === 'string') { - appendMessage('system', system); - } else if (Array.isArray(system)) { - const merged = system.map((item) => parseClaudeMessageContent(item)).filter((item) => item.length > 0).join('\n\n'); - if (merged) appendMessage('system', merged); - } - - const rawMessages = Array.isArray(body.messages) ? body.messages : []; - for (const message of rawMessages) { - if (!isRecord(message)) continue; - const role = typeof message.role === 'string' ? message.role : 'user'; - const mappedRole = role === 'assistant' || role === 'system' ? role : 'user'; - const content = message.content; - - if (!Array.isArray(content)) { - appendMessage(mappedRole, content); - continue; - } - - // Claude tool blocks need explicit OpenAI mapping: - // - assistant.tool_use -> assistant.tool_calls - // - user.tool_result -> tool messages with tool_call_id - const textParts: string[] = []; - const toolCalls: Array> = []; - - const flushTextAsMessage = () => { - const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n'); - textParts.length = 0; - if (!merged) return; - messages.push({ role: mappedRole, content: merged }); - }; - - for (const block of content) { - if (!isRecord(block)) continue; - const blockType = typeof block.type === 'string' ? block.type : ''; - - if (blockType === 'tool_result') { - flushTextAsMessage(); - appendToolResultMessage(block.tool_use_id, block.content); - continue; - } - - if (mappedRole === 'assistant' && blockType === 'tool_use') { - const id = typeof block.id === 'string' && block.id.trim().length > 0 - ? block.id.trim() - : `call_${toolCalls.length}`; - const name = typeof block.name === 'string' ? block.name.trim() : ''; - const rawInput = block.input; - const args = ( - typeof rawInput === 'string' - ? rawInput - : stringifyUnknownValue(rawInput) || '{}' - ); - - const fn: Record = { arguments: args }; - if (name) fn.name = name; - - toolCalls.push({ - id, - type: 'function', - function: fn, - }); - continue; - } - - const text = parseClaudeMessageContent(block); - if (text) textParts.push(text); - } - - const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n'); - if (toolCalls.length > 0) { - const assistantMessage: Record = { - role: 'assistant', - tool_calls: toolCalls, - }; - // Keep textual assistant preface when present. - assistantMessage.content = merged || ''; - messages.push(assistantMessage); - } else if (merged) { - messages.push({ - role: mappedRole, - content: merged, - }); - } - } - - const payload: Record = { - model, - stream, - messages, - }; - - const temperature = pickFiniteNumber(body.temperature); - if (temperature !== undefined) payload.temperature = temperature; - - const topP = pickFiniteNumber(body.top_p); - if (topP !== undefined) payload.top_p = topP; - - const maxTokens = pickFiniteNumber(body.max_tokens); - if (maxTokens !== undefined) { - payload.max_tokens = maxTokens; - } else { - payload.max_tokens = 4096; - } - - if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) { - payload.stop = body.stop_sequences; - } - - if (body.tools !== undefined) payload.tools = body.tools; - if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice; - - return { model, stream, messages, payload }; -} - -export function parseDownstreamChatRequest( - body: unknown, - format: DownstreamFormat, -): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } { - const raw = isRecord(body) ? body : {}; - - if (format === 'claude') { - const converted = convertClaudeRequestToOpenAiBody(raw); - if (!converted.model) { - return { - error: { - statusCode: 400, - payload: { error: { message: 'model is required', type: 'invalid_request_error' } }, - }, - }; - } - - if (converted.messages.length <= 0) { - return { - error: { - statusCode: 400, - payload: { error: { message: 'messages is required', type: 'invalid_request_error' } }, - }, - }; - } - - return { - value: { - requestedModel: converted.model, - isStream: converted.stream, - upstreamBody: converted.payload, - claudeOriginalBody: raw, - }, - }; - } - - const model = typeof raw.model === 'string' ? raw.model.trim() : ''; - if (!model) { - return { - error: { - statusCode: 400, - payload: { error: { message: 'model is required', type: 'invalid_request_error' } }, - }, - }; - } - - const hasMessages = Array.isArray(raw.messages) && raw.messages.length > 0; - if (!hasMessages) { - const hint = raw.input !== undefined - ? 'messages is required for /v1/chat/completions. For Responses payload, use /v1/responses.' - : 'messages is required'; - return { - error: { - statusCode: 400, - payload: { error: { message: hint, type: 'invalid_request_error' } }, - }, - }; - } - - return { - value: { - requestedModel: model, - isStream: raw.stream === true, - upstreamBody: raw, - }, - }; -} - -export function normalizeUpstreamFinalResponse( - payload: unknown, - fallbackModel: string, - fallbackText = '', -): NormalizedFinalResponse { - const now = Math.floor(Date.now() / 1000); - const fallbackId = `chatcmpl-meta-${Date.now()}`; - - if (isRecord(payload) && Array.isArray(payload.choices)) { - const choice = payload.choices[0] ?? {}; - const content = extractAssistantContent(choice) || extractAssistantContent(payload); - const reasoning = extractAssistantReasoning(choice) || extractAssistantReasoning(payload); - const toolCalls = collectToolCallsFromOpenAiChoice(choice); - return { - id: isNonEmptyString(payload.id) ? payload.id : fallbackId, - model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, - created: ensureIntegerTimestamp(payload.created, now), - content: content || (toolCalls.length > 0 ? '' : fallbackText), - reasoningContent: reasoning, - finishReason: toolCalls.length > 0 - ? 'tool_calls' - : (normalizeStopReason(choice?.finish_reason ?? payload.stop_reason) || 'stop'), - toolCalls, - }; - } - - if (isRecord(payload) && typeof payload.type === 'string' && payload.type === 'message') { - const toolCalls = collectToolCallsFromClaudeContent(payload.content); - return { - id: isNonEmptyString(payload.id) ? payload.id : fallbackId, - model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, - created: now, - content: parseClaudeMessageContent(payload.content) || (toolCalls.length > 0 ? '' : fallbackText), - reasoningContent: extractTextAndReasoning(payload.content).reasoning, - finishReason: toolCalls.length > 0 ? 'tool_calls' : (normalizeStopReason(payload.stop_reason) || 'stop'), - toolCalls, - }; - } - - if (isRecord(payload) && ((payload as any).object === 'response' || Array.isArray((payload as any).output))) { - const toolCalls = collectToolCallsFromResponsesPayload(payload); - return { - id: isNonEmptyString(payload.id) ? payload.id : fallbackId, - model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, - created: ensureIntegerTimestamp(payload.created, now), - content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText), - reasoningContent: '', - finishReason: toolCalls.length > 0 - ? 'tool_calls' - : (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'), - toolCalls, - }; - } - - if (isRecord(payload) && Array.isArray(payload.candidates)) { - const candidate = payload.candidates[0] || {}; - const parsedCandidate = extractTextAndReasoning(candidate?.content?.parts || candidate?.content); - return { - id: isNonEmptyString((payload as any).responseId) ? (payload as any).responseId : fallbackId, - model: isNonEmptyString((payload as any).modelVersion) - ? (payload as any).modelVersion - : fallbackModel, - created: now, - content: parsedCandidate.content || fallbackText, - reasoningContent: parsedCandidate.reasoning, - finishReason: normalizeStopReason(candidate?.finishReason || (payload as any).finishReason) || 'stop', - toolCalls: [], - }; - } - - if (typeof payload === 'string' && payload.trim()) { - return { - id: fallbackId, - model: fallbackModel, - created: now, - content: payload, - reasoningContent: '', - finishReason: 'stop', - toolCalls: [], - }; - } - - return { - id: fallbackId, - model: fallbackModel, - created: now, - content: fallbackText, - reasoningContent: '', - finishReason: 'stop', - toolCalls: [], - }; -} - -export function normalizeUpstreamStreamEvent( - payload: unknown, - context: StreamTransformContext, - fallbackModel: string, -): NormalizedStreamEvent { - if (!isRecord(payload)) return {}; - - if (Array.isArray(payload.choices)) { - if (isNonEmptyString(payload.id)) context.id = payload.id; - if (isNonEmptyString(payload.model)) context.model = payload.model; - context.created = ensureIntegerTimestamp(payload.created, context.created); - - const choice = payload.choices[0] ?? {}; - const delta = isRecord(choice?.delta) ? choice.delta : {}; - const deltaParsed = extractTextAndReasoning(delta.content ?? delta); - - const rawContentDelta = - deltaParsed.content - || (typeof choice?.message?.content === 'string' ? choice.message.content : '') - || ''; - - const reasoningDelta = - (typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '') - || (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '') - || deltaParsed.reasoning - || ''; - - // Some upstream providers (e.g. certain OpenAI-compatible aggregators) emit thinking - // tokens with the same text duplicated in both delta.content and delta.reasoning_content. - // When the two values are identical it means content is just echoing the reasoning — - // suppress it so internal thinking is never leaked to downstream consumers. - const contentDelta = (reasoningDelta && rawContentDelta === reasoningDelta) - ? '' - : rawContentDelta; - - const rawToolCalls = Array.isArray((delta as any).tool_calls) - ? ((delta as any).tool_calls as unknown[]) - : []; - const toolCallDeltas = rawToolCalls - .map((item, itemIndex) => { - if (!isRecord(item)) return null; - const functionPart = isRecord(item.function) ? item.function : {}; - const index = ( - typeof item.index === 'number' && Number.isFinite(item.index) - ? Math.max(0, Math.trunc(item.index)) - : itemIndex - ); - const id = typeof item.id === 'string' && item.id.trim().length > 0 - ? item.id - : undefined; - const name = typeof functionPart.name === 'string' && functionPart.name.trim().length > 0 - ? functionPart.name - : undefined; - const argumentsDelta = typeof functionPart.arguments === 'string' - ? functionPart.arguments - : undefined; - - if (!id && !name && argumentsDelta === undefined) return null; - return { - index, - id, - name, - argumentsDelta, - }; - }) - .filter((item): item is NonNullable => !!item); - - return { - role: (delta as any).role === 'assistant' ? 'assistant' : undefined, - contentDelta: contentDelta || undefined, - reasoningDelta: reasoningDelta || undefined, - toolCallDeltas: toolCallDeltas.length > 0 ? toolCallDeltas : undefined, - finishReason: normalizeStopReason(choice?.finish_reason), - }; - } - - const type = typeof payload.type === 'string' ? payload.type : ''; - if (type.startsWith('response.output_text')) { - const deltaText = typeof payload.delta === 'string' - ? payload.delta - : extractTextAndReasoning(payload.delta).content; - return { - contentDelta: deltaText || undefined, - }; - } - - if (type === 'response.reasoning_summary_text.delta') { - const deltaText = typeof payload.delta === 'string' - ? payload.delta - : extractTextAndReasoning(payload.delta).content; - return { - reasoningDelta: deltaText || undefined, - }; - } - - if (type === 'response.failed' || type === 'response.incomplete' || type === 'error') { - const responsePayload = isRecord((payload as any).response) ? (payload as any).response : null; - const finishReason = normalizeStopReason( - (responsePayload as any)?.status - ?? (payload as any).status - ?? (payload as any).type, - ) || 'error'; - return { - finishReason, - done: true, - }; - } - - if (type === 'response.output_item.added' && isRecord((payload as any).item)) { - const item = (payload as any).item as Record; - if (item.type === 'function_call') { - const outputIndex = ( - typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index) - ? Math.max(0, Math.trunc((payload as any).output_index)) - : 0 - ); - const toolCallId = ( - isNonEmptyString(item.call_id) ? item.call_id - : (isNonEmptyString(item.id) ? item.id : undefined) - ); - const toolName = isNonEmptyString(item.name) ? item.name : undefined; - return { - toolCallDeltas: [{ - index: outputIndex, - id: toolCallId, - name: toolName, - }], - }; - } - } - - if (type === 'response.function_call_arguments.delta' || type === 'response.function_call_arguments.done') { - const outputIndex = ( - typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index) - ? Math.max(0, Math.trunc((payload as any).output_index)) - : 0 - ); - const toolCallId = ( - isNonEmptyString((payload as any).call_id) ? (payload as any).call_id - : (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined) - ); - const toolName = isNonEmptyString((payload as any).name) ? (payload as any).name : undefined; - const rawArguments = ( - type === 'response.function_call_arguments.done' - ? (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined) - : ( - typeof payload.delta === 'string' - ? payload.delta - : (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined) - ) - ); - let argumentsDelta = rawArguments; - if (type === 'response.function_call_arguments.done' && typeof rawArguments === 'string') { - const existingArguments = context.toolCalls[outputIndex]?.arguments || ''; - if (existingArguments && rawArguments.startsWith(existingArguments)) { - const missingSuffix = rawArguments.slice(existingArguments.length); - argumentsDelta = missingSuffix.length > 0 ? missingSuffix : undefined; - } else if (existingArguments === rawArguments) { - argumentsDelta = undefined; - } - } - - const knownTool = context.toolCalls[outputIndex] || {}; - const shouldBackfillId = !!toolCallId && !knownTool.id; - const shouldBackfillName = !!toolName && !knownTool.name; - if (argumentsDelta === undefined && !shouldBackfillId && !shouldBackfillName) { - return {}; - } - - return { - toolCallDeltas: [{ - index: outputIndex, - id: toolCallId, - name: toolName, - argumentsDelta, - }], - }; - } - - if (type === 'response.completed' && isRecord((payload as any).response)) { - const responsePayload = (payload as any).response as Record; - if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id; - if (isNonEmptyString(responsePayload.model)) context.model = responsePayload.model; - return { - finishReason: normalizeStopReason(responsePayload.status) || 'stop', - done: true, - }; - } - - const message = isRecord(payload.message) ? payload.message : null; - - if (message) { - if (isNonEmptyString(message.id)) context.id = message.id; - if (isNonEmptyString(message.model)) context.model = message.model; - } - if (!context.model) context.model = fallbackModel; - - if (type === 'message_start') { - return { role: 'assistant' }; - } - - if (type === 'content_block_start') { - const index = ( - typeof (payload as any).index === 'number' && Number.isFinite((payload as any).index) - ? Math.max(0, Math.trunc((payload as any).index)) - : 0 - ); - const contentBlock = isRecord(payload.content_block) ? payload.content_block : {}; - if (contentBlock.type === 'tool_use') { - const id = typeof contentBlock.id === 'string' && contentBlock.id.trim().length > 0 - ? contentBlock.id - : undefined; - const name = typeof contentBlock.name === 'string' && contentBlock.name.trim().length > 0 - ? contentBlock.name - : undefined; - let argumentsDelta: string | undefined; - const rawInput = contentBlock.input; - if (typeof rawInput === 'string') { - argumentsDelta = rawInput; - } else if (Array.isArray(rawInput) || isRecord(rawInput)) { - try { - const serialized = JSON.stringify(rawInput); - if (serialized && serialized !== '{}' && serialized !== '[]') { - argumentsDelta = serialized; - } - } catch {} - } - - return { - toolCallDeltas: [{ - index, - id, - name, - argumentsDelta, - }], - }; - } - - const parsed = extractTextAndReasoning(payload.content_block); - return { - contentDelta: parsed.content || undefined, - reasoningDelta: parsed.reasoning || undefined, - }; - } - - if (type === 'content_block_delta') { - const delta = isRecord(payload.delta) ? payload.delta : {}; - const deltaType = typeof delta.type === 'string' ? delta.type : ''; - const parsed = extractTextAndReasoning(delta); - - if (deltaType === 'input_json_delta') { - const index = ( - typeof (payload as any).index === 'number' && Number.isFinite((payload as any).index) - ? Math.max(0, Math.trunc((payload as any).index)) - : 0 - ); - const partialJson = typeof (delta as any).partial_json === 'string' - ? (delta as any).partial_json - : undefined; - return { - toolCallDeltas: [{ - index, - argumentsDelta: partialJson, - }], - }; - } - - if (deltaType === 'thinking_delta') { - return { - reasoningDelta: parsed.content || parsed.reasoning || undefined, - }; - } - - return { - contentDelta: parsed.content || undefined, - reasoningDelta: parsed.reasoning || undefined, - }; - } - - if (type === 'message_delta') { - const delta = isRecord(payload.delta) ? payload.delta : {}; - return { - finishReason: normalizeStopReason(delta.stop_reason ?? payload.stop_reason), - }; - } - - if (type === 'message_stop') { - return { done: true }; - } - - if (Array.isArray(payload.candidates)) { - const candidate = payload.candidates[0] || {}; - const parsed = extractTextAndReasoning((candidate as any).content?.parts || (candidate as any).content); - - if (isNonEmptyString((payload as any).modelVersion)) { - context.model = (payload as any).modelVersion; - } else if (!context.model) { - context.model = fallbackModel; - } - - return { - contentDelta: parsed.content || undefined, - reasoningDelta: parsed.reasoning || undefined, - finishReason: normalizeStopReason((candidate as any).finishReason || (payload as any).finishReason), - }; - } - - const fallback = extractTextAndReasoning(payload); - return { - contentDelta: fallback.content || undefined, - reasoningDelta: fallback.reasoning || undefined, - }; -} - -function buildOpenAiStreamChunk( - context: StreamTransformContext, - event: NormalizedStreamEvent, -): Record | null { - const delta: Record = {}; - const isInitialAssistantRoleOnlyEvent = ( - !context.roleSent - && event.role === 'assistant' - && !event.contentDelta - && !event.reasoningDelta - ); - - if (!context.roleSent && (event.role === 'assistant' || event.contentDelta || event.reasoningDelta)) { - delta.role = 'assistant'; - context.roleSent = true; - } else if (event.role === 'assistant') { - delta.role = 'assistant'; - context.roleSent = true; - } - - if (event.contentDelta) { - delta.content = event.contentDelta; - } - - if (event.reasoningDelta) { - delta.reasoning_content = event.reasoningDelta; - } - - if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { - const toolCalls = event.toolCallDeltas.map((toolDelta) => { - const index = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0; - const existing = context.toolCalls[index] || {}; - const id = toolDelta.id || existing.id || `call_meta_${index}`; - const name = toolDelta.name || existing.name || ''; - const nextArguments = `${existing.arguments || ''}${toolDelta.argumentsDelta ?? ''}`; - context.toolCalls[index] = { - id, - name: name || existing.name, - arguments: nextArguments, - }; - - const fn: Record = {}; - if (name) fn.name = name; - if (toolDelta.argumentsDelta !== undefined) fn.arguments = toolDelta.argumentsDelta; - - return { - index, - id, - type: 'function', - function: fn, - }; - }); - - if (toolCalls.length > 0) { - delta.tool_calls = toolCalls; - } - } - - // Some OpenAI-compatible clients (e.g. OpenWebUI) expect starter chunk to include empty content. - if (isInitialAssistantRoleOnlyEvent) { - delta.content = ''; - } - - const finishReason = event.finishReason || null; - const hasDelta = Object.keys(delta).length > 0; - if (!hasDelta && !finishReason) return null; - - return { - id: context.id, - object: 'chat.completion.chunk', - created: context.created, - model: context.model, - choices: [{ - index: 0, - delta, - finish_reason: finishReason, - }], - }; -} - -function ensureClaudeStartEvents( - context: StreamTransformContext, - claudeContext: ClaudeDownstreamContext, -): string[] { - if (claudeContext.messageStarted) return []; - - claudeContext.messageStarted = true; - const payload = { - type: 'message_start', - message: { - id: buildClaudeMessageId(context.id), - type: 'message', - role: 'assistant', - model: context.model, - content: [], - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 0, - output_tokens: 0, - }, - }, - }; - - return [serializeSse('message_start', payload)]; -} - -function ensureClaudeTextBlockStart( - claudeContext: ClaudeDownstreamContext, -): string[] { - if (claudeContext.contentBlockStarted && claudeContext.textBlockIndex !== null) return []; - const contentIndex = claudeContext.nextContentBlockIndex; - claudeContext.nextContentBlockIndex += 1; - claudeContext.contentBlockStarted = true; - claudeContext.textBlockIndex = contentIndex; - - return [serializeSse('content_block_start', { - type: 'content_block_start', - index: contentIndex, - content_block: { - type: 'text', - text: '', - }, - })]; -} - -function closeClaudeTextBlock( - claudeContext: ClaudeDownstreamContext, -): string[] { - if (!claudeContext.contentBlockStarted || claudeContext.textBlockIndex === null) return []; - - const contentIndex = claudeContext.textBlockIndex; - claudeContext.contentBlockStarted = false; - claudeContext.textBlockIndex = null; - return [serializeSse('content_block_stop', { - type: 'content_block_stop', - index: contentIndex, - })]; -} - -function normalizeToolContentIndex(raw: unknown): number { - if (typeof raw === 'number' && Number.isFinite(raw)) { - return Math.max(0, Math.trunc(raw)); - } - return 0; -} - -function ensureClaudeToolBlockStart( - claudeContext: ClaudeDownstreamContext, - toolDelta: NonNullable[number], -): { events: string[]; contentIndex: number } { - const toolSlot = normalizeToolContentIndex(toolDelta.index); - let state = claudeContext.toolBlocks[toolSlot]; - if (!state) { - const fallbackId = `toolu_meta_${toolSlot}`; - const fallbackName = `tool_${toolSlot}`; - state = { - contentIndex: claudeContext.nextContentBlockIndex, - id: toolDelta.id || fallbackId, - name: toolDelta.name || fallbackName, - open: false, - }; - claudeContext.nextContentBlockIndex += 1; - claudeContext.toolBlocks[toolSlot] = state; - } else { - if (toolDelta.id && state.id !== toolDelta.id) { - state.id = toolDelta.id; - } - if (toolDelta.name && state.name !== toolDelta.name) { - state.name = toolDelta.name; - } - } - - const events: string[] = []; - if (!state.open) { - state.open = true; - events.push(serializeSse('content_block_start', { - type: 'content_block_start', - index: state.contentIndex, - content_block: { - type: 'tool_use', - id: state.id, - name: state.name, - input: {}, - }, - })); - } - - return { - events, - contentIndex: state.contentIndex, - }; -} - -function closeClaudeToolBlocks( - claudeContext: ClaudeDownstreamContext, -): string[] { - const openBlocks = Object.values(claudeContext.toolBlocks) - .filter((item) => item.open) - .sort((a, b) => a.contentIndex - b.contentIndex); - if (openBlocks.length <= 0) return []; - - const events: string[] = []; - for (const block of openBlocks) { - block.open = false; - events.push(serializeSse('content_block_stop', { - type: 'content_block_stop', - index: block.contentIndex, - })); - } - return events; -} - -function buildClaudeDoneEvents( - context: StreamTransformContext, - claudeContext: ClaudeDownstreamContext, - finishReason?: string | null, -): string[] { - if (claudeContext.doneSent) return []; - - const events: string[] = []; - events.push(...ensureClaudeStartEvents(context, claudeContext)); - - events.push(...closeClaudeTextBlock(claudeContext)); - events.push(...closeClaudeToolBlocks(claudeContext)); - - events.push(serializeSse('message_delta', { - type: 'message_delta', - delta: { - stop_reason: toClaudeStopReason(finishReason), - stop_sequence: null, - }, - usage: { - output_tokens: 0, - }, - })); - events.push(serializeSse('message_stop', { type: 'message_stop' })); - - claudeContext.doneSent = true; - return events; -} - -export function serializeNormalizedStreamEvent( - downstreamFormat: DownstreamFormat, - event: NormalizedStreamEvent, - context: StreamTransformContext, - claudeContext: ClaudeDownstreamContext, -): string[] { - if (downstreamFormat === 'openai') { - const chunk = buildOpenAiStreamChunk(context, event); - return chunk ? [serializeSse('', chunk)] : []; - } - - const events: string[] = []; - if (event.role === 'assistant' || event.contentDelta || event.reasoningDelta) { - events.push(...ensureClaudeStartEvents(context, claudeContext)); - } - - const mergedText = joinNonEmpty([ - event.reasoningDelta || '', - event.contentDelta || '', - ]); - - if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { - events.push(...closeClaudeTextBlock(claudeContext)); - for (const toolDelta of event.toolCallDeltas) { - const toolBlock = ensureClaudeToolBlockStart(claudeContext, toolDelta); - events.push(...toolBlock.events); - - if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) { - events.push(serializeSse('content_block_delta', { - type: 'content_block_delta', - index: toolBlock.contentIndex, - delta: { - type: 'input_json_delta', - partial_json: toolDelta.argumentsDelta, - }, - })); - } - } - } - - if (mergedText) { - events.push(...closeClaudeToolBlocks(claudeContext)); - events.push(...ensureClaudeTextBlockStart(claudeContext)); - events.push(serializeSse('content_block_delta', { - type: 'content_block_delta', - index: claudeContext.textBlockIndex ?? 0, - delta: { - type: 'text_delta', - text: mergedText, - }, - })); - } - - if (event.done || event.finishReason) { - events.push(...buildClaudeDoneEvents(context, claudeContext, event.finishReason)); - } - - return events; -} - -export function serializeStreamDone( - downstreamFormat: DownstreamFormat, - context: StreamTransformContext, - claudeContext: ClaudeDownstreamContext, -): string[] { - if (context.doneSent) return []; - context.doneSent = true; - - if (downstreamFormat === 'openai') { - return [serializeSse('', '[DONE]')]; - } - - return buildClaudeDoneEvents(context, claudeContext, 'stop'); -} - -function toOpenAiToolCalls( - toolCalls: Array<{ id: string; name: string; arguments: string }>, -): Array> { - return toolCalls.map((toolCall, index) => ({ - index, - id: toolCall.id || `call_${index}`, - type: 'function', - function: { - name: toolCall.name || '', - arguments: toolCall.arguments || '', - }, - })); -} - -export function serializeFinalResponse( - downstreamFormat: DownstreamFormat, - normalized: NormalizedFinalResponse, - usage: { promptTokens: number; completionTokens: number; totalTokens: number }, -): Record { - const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : []; - - if (downstreamFormat === 'claude') { - const contentBlocks: Array> = []; - if (normalized.reasoningContent) { - contentBlocks.push({ - type: 'thinking', - thinking: normalized.reasoningContent, - }); - } - if (normalized.content) { - contentBlocks.push({ - type: 'text', - text: normalized.content, - }); - } - for (let index = 0; index < toolCalls.length; index += 1) { - const toolCall = toolCalls[index]; - contentBlocks.push({ - type: 'tool_use', - id: toolCall.id || `toolu_${index}`, - name: toolCall.name || `tool_${index}`, - input: parseJsonLike(toolCall.arguments || ''), - }); - } - - const content = contentBlocks.length > 0 - ? contentBlocks - : [{ type: 'text', text: '' }]; - - return { - id: buildClaudeMessageId(normalized.id), - type: 'message', - role: 'assistant', - model: normalized.model, - content, - stop_reason: toClaudeStopReason(normalized.finishReason), - stop_sequence: null, - usage: { - input_tokens: usage.promptTokens, - output_tokens: usage.completionTokens, - }, - }; - } - - const message: Record = { - role: 'assistant', - content: normalized.content, - }; - if (normalized.reasoningContent) { - message.reasoning_content = normalized.reasoningContent; - } - if (toolCalls.length > 0) { - message.tool_calls = toOpenAiToolCalls(toolCalls); - if (!normalized.content) message.content = ''; - } - - const finishReason = ( - toolCalls.length > 0 - ? 'tool_calls' - : (normalizeStopReason(normalized.finishReason) || 'stop') - ); - - return { - id: normalized.id, - object: 'chat.completion', - created: normalized.created, - model: normalized.model, - choices: [{ - index: 0, - message, - finish_reason: finishReason, - }], - usage: { - prompt_tokens: usage.promptTokens, - completion_tokens: usage.completionTokens, - total_tokens: usage.totalTokens, - }, - }; -} - -export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse): Array> { - const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : []; - const finishReason = ( - toolCalls.length > 0 - ? 'tool_calls' - : (normalizeStopReason(normalized.finishReason) || 'stop') - ); - - const startDelta: Record = { - role: 'assistant', - }; - if (normalized.content) { - startDelta.content = normalized.content; - } else { - startDelta.content = ''; - } - if (normalized.reasoningContent) { - startDelta.reasoning_content = normalized.reasoningContent; - } - if (toolCalls.length > 0) { - startDelta.tool_calls = toOpenAiToolCalls(toolCalls); - } - - const startChunk: Record = { - id: normalized.id, - object: 'chat.completion.chunk', - created: normalized.created, - model: normalized.model, - choices: [{ - index: 0, - delta: startDelta, - finish_reason: null, - }], - }; - - const endChunk = { - id: normalized.id, - object: 'chat.completion.chunk', - created: normalized.created, - model: normalized.model, - choices: [{ - index: 0, - delta: {}, - finish_reason: finishReason, - }], - }; - - return [startChunk, endChunk]; -} - -export function pullSseEventsWithDone(buffer: string): { events: ParsedSseEvent[]; rest: string } { - const normalized = buffer.replace(/\r\n/g, '\n'); - const events: ParsedSseEvent[] = []; - let rest = normalized; - - while (true) { - const boundary = rest.indexOf('\n\n'); - if (boundary < 0) break; - - const block = rest.slice(0, boundary); - rest = rest.slice(boundary + 2); - - if (!block.trim()) continue; - - const lines = block.split('\n'); - let eventName = ''; - const dataLines: string[] = []; - - for (const line of lines) { - if (line.startsWith('event:')) { - eventName = line.slice(6).trim(); - continue; - } - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trimStart()); - } - } - - if (dataLines.length <= 0) continue; - - events.push({ - event: eventName, - data: dataLines.join('\n').trim(), - }); - } - - return { events, rest }; -} +export { + buildSyntheticOpenAiChunks, + createClaudeDownstreamContext, + createStreamTransformContext, + normalizeStopReason, + normalizeUpstreamFinalResponse, + normalizeUpstreamStreamEvent, + parseDownstreamChatRequest, + pullSseEventsWithDone, + serializeFinalResponse, + serializeNormalizedStreamEvent, + serializeStreamDone, + toClaudeStopReason, + type ClaudeDownstreamContext, + type DownstreamFormat, + type NormalizedFinalResponse, + type NormalizedStreamEvent, + type ParsedDownstreamChatRequest, + type ParsedSseEvent, + type StreamTransformContext, +} from '../../transformers/shared/chatFormatsCore.js'; diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts new file mode 100644 index 0000000..2f5ddc4 --- /dev/null +++ b/src/server/routes/proxy/gemini.test.ts @@ -0,0 +1,430 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const invalidateTokenRouterCacheMock = vi.fn(); +const authorizeDownstreamTokenMock = vi.fn(); +const consumeManagedKeyRequestMock = vi.fn(); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + explainSelection: vi.fn(async () => ({ selectedChannelId: 11 })), + }, + invalidateTokenRouterCache: (...args: unknown[]) => invalidateTokenRouterCacheMock(...args), +})); + +vi.mock('../../services/downstreamApiKeyService.js', () => ({ + authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args), + consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args), +})); + +describe('gemini native proxy routes', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { proxyRoutes } = await import('./router.js'); + app = Fastify(); + await app.register(proxyRoutes); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + authorizeDownstreamTokenMock.mockReset(); + consumeManagedKeyRequestMock.mockReset(); + + authorizeDownstreamTokenMock.mockResolvedValue({ + ok: true, + source: 'global', + token: 'sk-managed-gemini', + policy: {}, + }); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, 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', + }); + selectNextChannelMock.mockReturnValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('accepts x-goog-api-key on /v1beta/models and returns gemini model list shape', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + models: [ + { + name: 'models/gemini-2.5-flash', + displayName: 'Gemini 2.5 Flash', + }, + ], + }); + }); + + it('forwards native generateContent requests through the gemini route group', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: 'hello from gemini' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }), { + 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); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toContain('/v1beta/models/gemini-2.5-flash:generateContent'); + expect(targetUrl).toContain('key=gemini-key'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }); + expect(response.json()).toEqual({ + responseId: '', + modelVersion: '', + candidates: [ + { + index: 0, + content: { + parts: [{ text: 'hello from gemini' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }); + }); + + it('preserves structured Gemini-native fields instead of narrowing them to a bare passthrough shell', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: 'ok' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + cachedContentTokenCount: 2, + thoughtsTokenCount: 1, + }, + }), { + 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: { + systemInstruction: { + parts: [{ text: 'be concise' }], + }, + cachedContent: 'cached/abc123', + safetySettings: [{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }], + generationConfig: { + responseModalities: ['TEXT'], + responseMimeType: 'application/json', + temperature: 0.2, + topP: 0.8, + topK: 20, + maxOutputTokens: 256, + thinkingConfig: { thinkingBudget: 512 }, + imageConfig: { aspectRatio: '1:1' }, + }, + tools: [ + { + functionDeclarations: [ + { + name: 'lookup_weather', + description: 'look up weather', + }, + ], + }, + ], + contents: [ + { + role: 'user', + parts: [{ text: 'weather in shanghai' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(requestInit.body))).toEqual({ + systemInstruction: { + parts: [{ text: 'be concise' }], + }, + cachedContent: 'cached/abc123', + safetySettings: [{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }], + generationConfig: { + responseModalities: ['TEXT'], + responseMimeType: 'application/json', + temperature: 0.2, + topP: 0.8, + topK: 20, + maxOutputTokens: 256, + thinkingConfig: { thinkingBudget: 512 }, + imageConfig: { aspectRatio: '1:1' }, + }, + tools: [ + { + functionDeclarations: [ + { + name: 'lookup_weather', + description: 'look up weather', + }, + ], + }, + ], + contents: [ + { + role: 'user', + parts: [{ text: 'weather in shanghai' }], + }, + ], + }); + }); + + it('aggregates non-sse json-array streaming payloads into a final Gemini response', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([ + { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'first' }] }, + groundingMetadata: { source: 'web' }, + }, + ], + }, + { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'second', thoughtSignature: 'sig-1' }] }, + citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] }, + }, + ], + usageMetadata: { + promptTokenCount: 11, + candidatesTokenCount: 6, + totalTokenCount: 17, + cachedContentTokenCount: 2, + thoughtsTokenCount: 3, + }, + }, + ]), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + responseId: '', + modelVersion: '', + candidates: [ + { + index: 0, + finishReason: 'STOP', + content: { + role: 'model', + parts: [ + { text: 'first' }, + { text: 'second', thoughtSignature: 'sig-1' }, + ], + }, + groundingMetadata: { source: 'web' }, + citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] }, + }, + ], + usageMetadata: { + promptTokenCount: 11, + candidatesTokenCount: 6, + totalTokenCount: 17, + cachedContentTokenCount: 2, + thoughtsTokenCount: 3, + }, + }); + }); + + it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'gemini-site', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'gemini-key', + actualModel: 'gemini-3-pro', + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: 'ok' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-3-pro:generateContent', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + reasoning_effort: 'high', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(requestInit.body))).toEqual({ + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + generationConfig: { + thinkingConfig: { thinkingLevel: 'high' }, + }, + }); + }); + + it('aggregates SSE payloads through the transformer instead of raw passthrough', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"responseId":"resp-sse","modelVersion":"gemini-2.5-flash","candidates":[{"content":{"role":"model","parts":[{"text":"first"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n')); + controller.enqueue(encoder.encode('data: {"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(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(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('data: {"responseId":"resp-sse"'); + expect(response.body).toContain('"groundingMetadata":{"source":"web"}'); + expect(response.body).toContain('"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]}'); + expect(response.body).toContain('"cachedContentTokenCount":2'); + expect(response.body).toContain('"thoughtsTokenCount":3'); + expect(response.body).toContain('"parts":[{"text":"first"},{"text":"second","thoughtSignature":"sig-1"}]'); + expect(response.body).not.toContain('\r\n\r\n'); + }); +}); diff --git a/src/server/routes/proxy/gemini.ts b/src/server/routes/proxy/gemini.ts new file mode 100644 index 0000000..51a88df --- /dev/null +++ b/src/server/routes/proxy/gemini.ts @@ -0,0 +1,196 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { TextDecoder } from 'node:util'; +import { fetch } from 'undici'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; +import { + geminiGenerateContentTransformer, +} from '../../transformers/gemini/generate-content/index.js'; + +const GEMINI_MODEL_PROBES = [ + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-1.5-flash', + 'gemini-pro', +]; + +async function selectGeminiChannel(request: FastifyRequest) { + const policy = getDownstreamRoutingPolicy(request); + for (const candidate of GEMINI_MODEL_PROBES) { + const selected = await tokenRouter.selectChannel(candidate, policy); + if (selected) return selected; + } + return null; +} + +function resolveGeminiApiVersion(request: FastifyRequest): string { + const params = request.params as { geminiApiVersion?: string } | undefined; + return (params?.geminiApiVersion || 'v1beta').trim() || 'v1beta'; +} + +function getRawRequestUrl(request: FastifyRequest): string { + return request.raw.url || request.url || ''; +} + +function extractGeminiModelActionPath(request: FastifyRequest, apiVersion: string): string { + const rawUrl = getRawRequestUrl(request); + const withoutQuery = rawUrl.split('?')[0] || rawUrl; + const normalizedVersion = apiVersion.replace(/^\/+/, ''); + const geminiPrefix = `/gemini/${normalizedVersion}/`; + const aliasPrefix = `/${normalizedVersion}/`; + if (withoutQuery.startsWith(geminiPrefix)) { + return withoutQuery.slice(geminiPrefix.length); + } + if (withoutQuery.startsWith(aliasPrefix)) { + return withoutQuery.slice(aliasPrefix.length); + } + return withoutQuery.replace(/^\/+/, ''); +} + +export async function geminiProxyRoute(app: FastifyInstance) { + const listModels = async (request: FastifyRequest, reply: FastifyReply) => { + const selected = await selectGeminiChannel(request); + if (!selected) { + return reply.code(503).send({ + error: { message: 'No available channels for Gemini models', type: 'server_error' }, + }); + } + + const apiVersion = resolveGeminiApiVersion(request); + const upstream = await fetch( + geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue), + { method: 'GET' }, + ); + const text = await upstream.text(); + try { + return reply.code(upstream.status).send(JSON.parse(text)); + } catch { + return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text); + } + }; + + const generateContent = async (request: FastifyRequest, reply: FastifyReply) => { + const apiVersion = resolveGeminiApiVersion(request); + const modelActionPath = extractGeminiModelActionPath(request, apiVersion); + const requestedModel = modelActionPath.replace(/^models\//, '').split(':')[0].trim(); + if (!requestedModel) { + return reply.code(400).send({ + error: { message: 'Gemini model path is required', type: 'invalid_request_error' }, + }); + } + + const policy = getDownstreamRoutingPolicy(request); + const selected = await tokenRouter.selectChannel(requestedModel, policy); + if (!selected) { + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + const body = geminiGenerateContentTransformer.inbound.normalizeRequest( + request.body || {}, + selected.actualModel || requestedModel, + ); + + const actualModelAction = modelActionPath.replace( + /^models\/[^:]+/, + `models/${selected.actualModel || requestedModel}`, + ); + const query = new URLSearchParams(request.query as Record).toString(); + const upstream = await fetch( + geminiGenerateContentTransformer.resolveActionUrl( + selected.site.url, + apiVersion, + actualModelAction, + selected.tokenValue, + query, + ), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + + const contentType = upstream.headers.get('content-type'); + if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { + reply.hijack(); + reply.raw.statusCode = upstream.status; + reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); + const reader = upstream.body?.getReader(); + if (!reader) { + reply.raw.end(); + return; + } + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + const decoder = new TextDecoder(); + let rest = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + const chunkText = decoder.decode(value, { stream: true }); + const parsed = geminiGenerateContentTransformer.stream.parseSsePayloads(rest + chunkText); + rest = parsed.rest; + for (const event of parsed.events) { + geminiGenerateContentTransformer.stream.applyAggregate(aggregateState, event); + reply.raw.write( + geminiGenerateContentTransformer.stream.serializeSsePayload( + geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState), + ), + ); + } + } + const tail = decoder.decode(); + if (tail) { + const parsed = geminiGenerateContentTransformer.stream.parseSsePayloads(rest + tail); + for (const event of parsed.events) { + geminiGenerateContentTransformer.stream.applyAggregate(aggregateState, event); + reply.raw.write( + geminiGenerateContentTransformer.stream.serializeSsePayload( + geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState), + ), + ); + } + } + } finally { + reader.releaseLock(); + reply.raw.end(); + } + return; + } + + const text = await upstream.text(); + try { + const parsed = JSON.parse(text); + if (!upstream.ok) { + return reply.code(upstream.status).send(parsed); + } + + const aggregateState = geminiGenerateContentTransformer.aggregator.createState(); + if (Array.isArray(parsed)) { + for (const chunk of geminiGenerateContentTransformer.stream.parseJsonArrayPayload(parsed)) { + geminiGenerateContentTransformer.aggregator.apply(aggregateState, chunk); + } + return reply.code(upstream.status).send( + geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState), + ); + } + + geminiGenerateContentTransformer.aggregator.apply(aggregateState, parsed); + return reply.code(upstream.status).send( + geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState), + ); + } catch { + return reply.code(upstream.status).type(contentType || 'application/json').send(text); + } + }; + + app.get('/v1beta/models', listModels); + app.get('/gemini/:geminiApiVersion/models', listModels); + app.post('/v1beta/models/*', generateContent); + app.post('/gemini/:geminiApiVersion/models/*', generateContent); +} diff --git a/src/server/routes/proxy/images.edits.test.ts b/src/server/routes/proxy/images.edits.test.ts new file mode 100644 index 0000000..9ff0ceb --- /dev/null +++ b/src/server/routes/proxy/images.edits.test.ts @@ -0,0 +1,160 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const estimateProxyCostMock = vi.fn(async () => 0); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: () => ({ + run: () => undefined, + }), +})); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: (arg: any) => estimateProxyCostMock(arg), +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + schema: { + proxyLogs: {}, + }, +})); + +function buildMultipartBody(boundary: string) { + return Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="model"\r\n\r\n` + + `gpt-image-1\r\n` + + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="prompt"\r\n\r\n` + + `edit this\r\n` + + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="image"; filename="cat.png"\r\n` + + `Content-Type: image/png\r\n\r\n` + + `pngdata\r\n` + + `--${boundary}--\r\n`, + ); +} + +describe('/v1/images/edits route', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { imagesProxyRoute } = await import('./images.js'); + app = Fastify(); + await app.register(imagesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + estimateProxyCostMock.mockClear(); + dbInsertMock.mockClear(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt-image', + }); + selectNextChannelMock.mockReturnValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('accepts multipart image edit requests and forwards them to /v1/images/edits', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + created: 1, + data: [{ b64_json: 'iVBORw0KGgo=' }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const boundary = 'metapi-boundary'; + const response = await app.inject({ + method: 'POST', + url: '/v1/images/edits', + headers: { + authorization: 'Bearer sk-demo', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: buildMultipartBody(boundary), + }); + + expect(response.statusCode).toBe(200); + const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://upstream.example.com/v1/images/edits'); + }); + + it('returns explicit not-supported error for /v1/images/variations', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/images/variations', + payload: { + model: 'gpt-image-1', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: { + message: 'Image variations are not supported', + type: 'invalid_request_error', + }, + }); + }); +}); diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 7b1ff32..1b87a17 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -11,11 +11,14 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; const MAX_RETRIES = 2; -export async function imagesProxyRoute(app: FastifyInstance) { - app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => { +export async function imagesProxyRoute(app: FastifyInstance) { + ensureMultipartBufferParser(app); + + app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => { const body = request.body as any; const requestedModel = body?.model || 'gpt-image-1'; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; @@ -112,25 +115,154 @@ export async function imagesProxyRoute(app: FastifyInstance) { return reply.code(502).send({ error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, }); - } - } - }); -} + } + } + }); + + app.post('/v1/images/edits', async (request: FastifyRequest, reply: FastifyReply) => { + const multipartForm = await parseMultipartFormData(request); + const jsonBody = (!multipartForm && request.body && typeof request.body === 'object') + ? request.body as Record + : null; + const requestedModel = typeof multipartForm?.get('model') === 'string' + ? String(multipartForm.get('model')).trim() + : (typeof jsonBody?.model === 'string' ? jsonBody.model.trim() : '') || 'gpt-image-1'; + + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= MAX_RETRIES) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + const targetUrl = `${selected.site.url}/v1/images/edits`; + const startTime = Date.now(); + + try { + const requestInit = multipartForm + ? withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + Authorization: `Bearer ${selected.tokenValue}`, + }, + body: cloneFormDataWithOverrides(multipartForm, { + model: selected.actualModel || requestedModel, + }) as any, + }) + : withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify({ + ...(jsonBody || {}), + model: selected.actualModel || requestedModel, + }), + }); + + const upstream = await fetch(targetUrl, requestInit); + const text = await upstream.text(); + if (!upstream.ok) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits'); + if (isTokenExpiredError({ status: upstream.status, message: text })) { + await reportTokenExpired({ + accountId: selected.account.id, + username: selected.account.username, + siteName: selected.site.name, + detail: `HTTP ${upstream.status}`, + }); + } + if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + retryCount++; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: `upstream returned HTTP ${upstream.status}`, + }); + return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); + } + + let data: any = {}; + 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, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }); + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits'); + 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, 0, '/v1/images/edits'); + if (retryCount < MAX_RETRIES) { + retryCount++; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: err.message || 'network failure', + }); + return reply.code(502).send({ + error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, + }); + } + } + }); + + app.post('/v1/images/variations', async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.code(400).send({ + error: { + message: 'Image variations are not supported', + type: 'invalid_request_error', + }, + }); + }); +} async function logProxy( selected: any, modelRequested: string, status: string, httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, - estimatedCost = 0, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + estimatedCost = 0, + downstreamPath = '/v1/images/generations', ) { try { const createdAt = formatUtcSqlDateTime(new Date()); const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath: '/v1/images/generations', + downstreamPath, errorMessage, }); await db.insert(schema.proxyLogs).values({ diff --git a/src/server/routes/proxy/models.test.ts b/src/server/routes/proxy/models.test.ts index 5077bd1..15462d0 100644 --- a/src/server/routes/proxy/models.test.ts +++ b/src/server/routes/proxy/models.test.ts @@ -284,4 +284,90 @@ describe('/v1/models route', () => { expect(ids).not.toContain('claude-opus-4-5'); expect(ids).not.toContain('claude-sonnet-4-5'); }); + + it('filters search pseudo models out of /v1/models', async () => { + const site = await db.insert(schema.sites).values({ + name: 'search-site', + url: 'https://search.example.com', + platform: 'openai', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'search-access-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'search-api-token', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: '__search', + available: true, + }, + { + accountId: account.id, + modelName: '__tavily_search', + available: true, + }, + { + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + }, + ]).run(); + + const searchRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: '__search', + enabled: true, + }).returning().get(); + + const llmRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4.1', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: searchRoute.id, + accountId: account.id, + tokenId: token.id, + sourceModel: '__search', + enabled: true, + }, + { + routeId: llmRoute.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'gpt-4.1', + enabled: true, + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/models', + headers: { + authorization: 'Bearer change-me-proxy-sk-token', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + object: 'list'; + data: Array<{ id: string }>; + }; + const ids = body.data.map((item) => item.id); + expect(ids).toContain('gpt-4.1'); + expect(ids).not.toContain('__search'); + expect(ids).not.toContain('__tavily_search'); + }); }); diff --git a/src/server/routes/proxy/models.ts b/src/server/routes/proxy/models.ts index 5c46c13..065ad9b 100644 --- a/src/server/routes/proxy/models.ts +++ b/src/server/routes/proxy/models.ts @@ -6,6 +6,12 @@ import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; +function isSearchPseudoModel(modelName: string): boolean { + const normalized = (modelName || '').trim().toLowerCase(); + if (!normalized) return false; + return normalized === '__search' || /^__.+_search$/.test(normalized); +} + export async function modelsProxyRoute(app: FastifyInstance) { app.get('/v1/models', async (request) => { const downstreamPolicy = getDownstreamRoutingPolicy(request); @@ -32,7 +38,9 @@ export async function modelsProxyRoute(app: FastifyInstance) { const deduped = Array.from(new Set([ ...rows.map((r) => r.modelName), ...routeAliases, - ])).sort(); + ])) + .filter((modelName) => !isSearchPseudoModel(modelName)) + .sort(); const allowed: string[] = []; for (const modelName of deduped) { if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) { diff --git a/src/server/routes/proxy/multipart.ts b/src/server/routes/proxy/multipart.ts new file mode 100644 index 0000000..d66d9fe --- /dev/null +++ b/src/server/routes/proxy/multipart.ts @@ -0,0 +1,71 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; + +type MultipartAwareFastify = FastifyInstance & { + __metapiMultipartParserRegistered?: boolean; +}; + +function getContentType(request: FastifyRequest): string { + return typeof request.headers['content-type'] === 'string' + ? request.headers['content-type'] + : ''; +} + +export function ensureMultipartBufferParser(app: FastifyInstance): void { + const target = app as MultipartAwareFastify; + if (target.__metapiMultipartParserRegistered) return; + + app.addContentTypeParser(/^multipart\/form-data(?:;.*)?$/i, { parseAs: 'buffer' }, (_request, body, done) => { + done(null, body); + }); + + target.__metapiMultipartParserRegistered = true; +} + +export function isMultipartRequest(request: FastifyRequest): boolean { + return /^multipart\/form-data(?:;.*)?$/i.test(getContentType(request)); +} + +export async function parseMultipartFormData(request: FastifyRequest): Promise { + if (!isMultipartRequest(request)) return null; + const contentType = getContentType(request); + const body = request.body; + if (!Buffer.isBuffer(body) && !(body instanceof Uint8Array)) return null; + + const response = new Response(new Blob([Buffer.from(body)]), { + headers: { + 'content-type': contentType, + }, + }); + return response.formData(); +} + +export function cloneFormDataWithOverrides( + formData: FormData, + overrides: Record, +): FormData { + const next = new FormData(); + const applied = new Set(); + + for (const [key, value] of formData.entries()) { + if (Object.prototype.hasOwnProperty.call(overrides, key)) { + next.append(key, overrides[key] ?? ''); + applied.add(key); + continue; + } + + if (typeof value === 'string') { + next.append(key, value); + continue; + } + + const fileLike = value as unknown as File; + next.append(key, value, fileLike.name || 'upload.bin'); + } + + for (const [key, value] of Object.entries(overrides)) { + if (applied.has(key)) continue; + next.append(key, value); + } + + return next; +} diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index ee1d50f..8c02901 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -9,13 +9,16 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { type NormalizedStreamEvent } from '../../transformers/shared/normalized.js'; +import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; +import { serializeResponsesFinalPayload } from '../../transformers/openai/responses/outbound.js'; +import { convertResponsesBodyToOpenAiBody } from '../../transformers/openai/responses/conversion.js'; import { - createStreamTransformContext, - normalizeUpstreamFinalResponse, - normalizeUpstreamStreamEvent, - pullSseEventsWithDone, - type NormalizedStreamEvent, -} from './chatFormats.js'; + completeResponsesStream, + createOpenAiResponsesAggregateState, + failResponsesStream, + serializeConvertedResponsesEvents, +} from '../../transformers/openai/responses/aggregator.js'; import { buildMinimalJsonHeadersForCompatibility, buildUpstreamEndpointRequest, @@ -248,894 +251,8 @@ function normalizeText(value: unknown): string { return ''; } -function stringifyJson(value: unknown): string { - try { - return JSON.stringify(value); - } catch { - return ''; - } -} - -function normalizeToolArguments(value: unknown): string { - if (typeof value === 'string') return value; - if (value === undefined || value === null) return ''; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value) || isRecord(value)) return stringifyJson(value); - return ''; -} - -function normalizeToolOutput(value: unknown): string { - const text = normalizeText(value).trim(); - if (text) return text; - if (typeof value === 'string') return value; - if (value === undefined || value === null) return ''; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value) || isRecord(value)) return stringifyJson(value); - return ''; -} - -type OpenAiToolCall = { - id: string; - type: 'function'; - function: { - name: string; - arguments: string; - }; -}; - -function toOpenAiToolCall(item: Record, fallbackIndex: number): OpenAiToolCall | null { - const callId = ( - asTrimmedString(item.call_id) - || asTrimmedString(item.id) - || `call_${Date.now()}_${fallbackIndex}` - ); - const name = asTrimmedString(item.name); - if (!name) return null; - - return { - id: callId, - type: 'function', - function: { - name, - arguments: normalizeToolArguments(item.arguments), - }, - }; -} - -function extractTextAndToolsFromResponsesMessage( - rawContent: unknown, - fallbackPrefix: string, -): { - text: string; - toolCalls: OpenAiToolCall[]; - toolResults: Array<{ id: string; content: string }>; -} { - const contentItems = Array.isArray(rawContent) ? rawContent : [rawContent]; - const textParts: string[] = []; - const toolCalls: OpenAiToolCall[] = []; - const toolResults: Array<{ id: string; content: string }> = []; - - for (let index = 0; index < contentItems.length; index += 1) { - const part = contentItems[index]; - if (!isRecord(part)) { - const text = normalizeText(part).trim(); - if (text) textParts.push(text); - continue; - } - - const partType = asTrimmedString(part.type).toLowerCase(); - if (partType === 'tool_use') { - const callId = asTrimmedString(part.id) || `call_${fallbackPrefix}_${index}`; - const name = asTrimmedString(part.name); - if (!name) continue; - - toolCalls.push({ - id: callId, - type: 'function', - function: { - name, - arguments: normalizeToolArguments(part.input), - }, - }); - continue; - } - - if (partType === 'tool_result') { - const id = asTrimmedString(part.tool_use_id) || asTrimmedString(part.id); - if (!id) continue; - toolResults.push({ - id, - content: normalizeToolOutput(part.content), - }); - continue; - } - - const text = normalizeText(part).trim(); - if (text) textParts.push(text); - } - - return { - text: textParts.join('\n').trim(), - toolCalls, - toolResults, - }; -} - -function convertResponsesToolsToOpenAi(rawTools: unknown): unknown { - if (!Array.isArray(rawTools)) return rawTools; - - const converted = rawTools - .map((item) => { - if (!isRecord(item)) return item; - const type = asTrimmedString(item.type).toLowerCase(); - - if (type !== 'function') return item; - - if (isRecord(item.function) && asTrimmedString(item.function.name)) { - return item; - } - - const name = asTrimmedString(item.name); - if (!name) return item; - - const fn: Record = { name }; - const description = asTrimmedString(item.description); - if (description) fn.description = description; - if (item.parameters !== undefined) fn.parameters = item.parameters; - if (item.strict !== undefined) fn.strict = item.strict; - - return { - type: 'function', - function: fn, - }; - }); - - return converted; -} - -function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown { - if (rawToolChoice === undefined) return undefined; - if (typeof rawToolChoice === 'string') return rawToolChoice; - if (!isRecord(rawToolChoice)) return rawToolChoice; - - const type = asTrimmedString(rawToolChoice.type).toLowerCase(); - if (type === 'function') { - if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) { - return rawToolChoice; - } - - const name = asTrimmedString(rawToolChoice.name); - if (!name) return 'required'; - return { - type: 'function', - function: { name }, - }; - } - - if (type === 'auto' || type === 'none' || type === 'required') { - return type; - } - - return rawToolChoice; -} - -function convertResponsesBodyToOpenAiBody( - body: Record, - modelName: string, - stream: boolean, -): Record { - const messages: Array> = []; - const input = body.input; - let functionCallIndex = 0; - let pendingToolCalls: OpenAiToolCall[] = []; - - const flushPendingToolCalls = () => { - if (pendingToolCalls.length <= 0) return; - messages.push({ - role: 'assistant', - content: '', - tool_calls: pendingToolCalls, - }); - pendingToolCalls = []; - }; - - const pushToolOutputMessage = (callIdRaw: unknown, outputRaw: unknown) => { - const toolCallId = asTrimmedString(callIdRaw); - if (!toolCallId) return; - const content = normalizeToolOutput(outputRaw); - messages.push({ - role: 'tool', - tool_call_id: toolCallId, - content, - }); - }; - - const pushUserMessage = (content: string) => { - if (!content) return; - messages.push({ role: 'user', content }); - }; - - const processInputItem = (item: unknown) => { - if (typeof item === 'string') { - flushPendingToolCalls(); - const text = item.trim(); - if (text) pushUserMessage(text); - return; - } - - if (!isRecord(item)) return; - - const itemType = asTrimmedString(item.type).toLowerCase(); - if (itemType === 'function_call') { - const toolCall = toOpenAiToolCall(item, functionCallIndex); - functionCallIndex += 1; - if (!toolCall) return; - pendingToolCalls.push(toolCall); - return; - } - - if (itemType === 'function_call_output') { - flushPendingToolCalls(); - pushToolOutputMessage(item.call_id ?? item.id, item.output ?? item.content); - return; - } - - flushPendingToolCalls(); - - const role = asTrimmedString(item.role).toLowerCase() || 'user'; - const messageContent = item.content ?? item.input ?? item; - const parsed = extractTextAndToolsFromResponsesMessage( - messageContent, - `${Date.now()}_${messages.length}`, - ); - - if (role === 'assistant') { - if (!parsed.text && parsed.toolCalls.length <= 0) return; - - const assistantMessage: Record = { - role: 'assistant', - content: parsed.text, - }; - if (parsed.toolCalls.length > 0) { - assistantMessage.tool_calls = parsed.toolCalls; - } - messages.push(assistantMessage); - return; - } - - const normalizedRole = role === 'system' || role === 'developer' - ? 'system' - : role === 'tool' - ? 'tool' - : 'user'; - - if (normalizedRole === 'tool') { - pushToolOutputMessage(item.tool_call_id ?? item.call_id ?? item.id, messageContent); - return; - } - - if (parsed.text) { - messages.push({ - role: normalizedRole, - content: parsed.text, - }); - } - - for (const toolResult of parsed.toolResults) { - messages.push({ - role: 'tool', - tool_call_id: toolResult.id, - content: toolResult.content, - }); - } - }; - - if (typeof input === 'string') { - const text = input.trim(); - if (text) { - messages.push({ role: 'user', content: text }); - } - } else if (Array.isArray(input)) { - for (const item of input) { - processInputItem(item); - } - } else if (isRecord(input)) { - processInputItem(input); - } - flushPendingToolCalls(); - - const instructions = asTrimmedString(body.instructions); - if (instructions) { - messages.unshift({ role: 'system', content: instructions }); - } - - const payload: Record = { - model: modelName, - stream, - messages, - }; - - if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) { - payload.temperature = body.temperature; - } - if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) { - payload.top_p = body.top_p; - } - if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) { - payload.max_tokens = body.max_output_tokens; - } - if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls; - if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools); - if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice); - - return payload; -} - type UsageSummary = ReturnType; -type ResponsesToolCall = { - id: string; - name: string; - arguments: string; -}; - -type ResponsesMessageItemState = { - itemId: string; - outputIndex: number; - contentIndex: number; - text: string; -}; - -type ResponsesToolItemState = { - toolIndex: number; - itemId: string; - callId: string; - outputIndex: number; - name: string; - arguments: string; -}; - -type ResponsesStreamState = { - started: boolean; - completed: boolean; - responseId: string; - model: string; - createdAt: number; - sequenceNumber: number; - outputCursor: number; - messageItem: ResponsesMessageItemState | null; - toolItems: Map; -}; - -function ensureResponseId(rawId: string): string { - const trimmed = rawId.trim() || `resp_${Date.now()}`; - return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`; -} - -function ensureMessageId(rawId: string): string { - const trimmed = rawId.trim() || `msg_${Date.now()}`; - return trimmed.startsWith('msg_') ? trimmed : `msg_${trimmed}`; -} - -function ensureFunctionCallId(rawId: string): string { - const trimmed = rawId.trim(); - if (!trimmed) return `call_${Date.now()}`; - return trimmed.startsWith('call_') ? trimmed : `call_${trimmed}`; -} - -function stringifyToolInput(input: unknown): string { - if (typeof input === 'string') return input; - if (!input || typeof input !== 'object') return ''; - try { - return JSON.stringify(input); - } catch { - return ''; - } -} - -function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] { - if (!isRecord(payload)) return []; - - if (Array.isArray(payload.choices)) { - const choice = payload.choices[0]; - const message = isRecord((choice as any)?.message) ? (choice as any).message : {}; - const toolCalls = Array.isArray((message as any).tool_calls) ? (message as any).tool_calls : []; - return toolCalls - .map((item: unknown, index: number) => { - if (!isRecord(item)) return null; - const fn = isRecord(item.function) ? item.function : {}; - const id = typeof item.id === 'string' && item.id.trim().length > 0 - ? item.id - : `call_${index}`; - const name = typeof fn.name === 'string' ? fn.name : ''; - const args = typeof fn.arguments === 'string' ? fn.arguments : ''; - return { - id: ensureFunctionCallId(id), - name, - arguments: args, - }; - }) - .filter((item: ResponsesToolCall | null): item is ResponsesToolCall => !!item); - } - - if (payload.type === 'message' && Array.isArray(payload.content)) { - return payload.content - .map((item: unknown, index: number) => { - if (!isRecord(item) || item.type !== 'tool_use') return null; - const id = typeof item.id === 'string' && item.id.trim().length > 0 - ? item.id - : `call_${index}`; - const name = typeof item.name === 'string' ? item.name : ''; - const args = stringifyToolInput(item.input); - return { - id: ensureFunctionCallId(id), - name, - arguments: args, - }; - }) - .filter((item: ResponsesToolCall | null): item is ResponsesToolCall => !!item); - } - - return []; -} - -function toResponsesPayload( - upstreamPayload: unknown, - normalized: ReturnType, - usage: UsageSummary, -): Record { - if (isRecord(upstreamPayload) && upstreamPayload.object === 'response') { - return upstreamPayload; - } - - const normalizedId = typeof normalized.id === 'string' && normalized.id.trim() - ? normalized.id.trim() - : `resp_${Date.now()}`; - const responseId = ensureResponseId(normalizedId); - const messageId = ensureMessageId(normalizedId); - const toolCalls = extractToolCallsFromUpstream(upstreamPayload); - - const output: Array> = []; - if (normalized.content || toolCalls.length === 0) { - output.push({ - id: messageId, - type: 'message', - role: 'assistant', - status: 'completed', - content: [{ - type: 'output_text', - text: normalized.content || '', - }], - }); - } - - for (const toolCall of toolCalls) { - output.push({ - id: toolCall.id, - type: 'function_call', - status: 'completed', - call_id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - }); - } - - return { - id: responseId, - object: 'response', - created: normalized.created, - status: 'completed', - model: normalized.model, - output, - output_text: normalized.content || '', - usage: { - input_tokens: usage.promptTokens, - output_tokens: usage.completionTokens, - total_tokens: usage.totalTokens, - }, - }; -} - -function createResponsesStreamState(modelName: string): ResponsesStreamState { - return { - started: false, - completed: false, - responseId: `resp_meta_${Date.now()}`, - model: modelName, - createdAt: Math.floor(Date.now() / 1000), - sequenceNumber: 0, - outputCursor: 0, - messageItem: null, - toolItems: new Map(), - }; -} - -function toResponsesUsage(usage: UsageSummary): Record { - return { - input_tokens: usage.promptTokens, - output_tokens: usage.completionTokens, - total_tokens: usage.totalTokens, - }; -} - -function nextSequence(state: ResponsesStreamState): number { - const value = state.sequenceNumber; - state.sequenceNumber += 1; - return value; -} - -function serializeResponsesSse(eventType: string, payload: Record): string { - return `event: ${eventType}\ndata: ${JSON.stringify(payload)}\n\n`; -} - -function emitResponsesEvent( - state: ResponsesStreamState, - eventType: string, - payload: Record, -): string { - return serializeResponsesSse(eventType, { - type: eventType, - sequence_number: nextSequence(state), - ...payload, - }); -} - -function buildResponseObject( - state: ResponsesStreamState, - status: 'in_progress' | 'completed' | 'failed', - usage?: UsageSummary, -): Record { - const response: Record = { - id: state.responseId, - object: 'response', - created_at: state.createdAt, - status, - model: state.model, - output: [], - }; - if (usage) { - response.usage = toResponsesUsage(usage); - } - return response; -} - -function ensureResponsesStarted( - state: ResponsesStreamState, - streamContext: { id: string; model: string; created: number }, -): string[] { - if (state.started) return []; - - state.started = true; - state.responseId = ensureResponseId(streamContext.id || state.responseId); - state.model = streamContext.model || state.model; - state.createdAt = streamContext.created || state.createdAt; - - const inProgress = buildResponseObject(state, 'in_progress'); - return [ - emitResponsesEvent(state, 'response.created', { response: inProgress }), - emitResponsesEvent(state, 'response.in_progress', { response: inProgress }), - ]; -} - -function ensureMessageItem(state: ResponsesStreamState): string[] { - if (state.messageItem) return []; - - const outputIndex = state.outputCursor; - state.outputCursor += 1; - const itemId = ensureMessageId(`${state.responseId}_${outputIndex}`); - state.messageItem = { - itemId, - outputIndex, - contentIndex: 0, - text: '', - }; - - return [ - emitResponsesEvent(state, 'response.output_item.added', { - output_index: outputIndex, - item: { - id: itemId, - type: 'message', - status: 'in_progress', - role: 'assistant', - content: [], - }, - }), - emitResponsesEvent(state, 'response.content_part.added', { - item_id: itemId, - output_index: outputIndex, - content_index: 0, - part: { - type: 'output_text', - text: '', - }, - }), - ]; -} - -function toIncrementalText(existingText: string, incomingText: string): string { - if (!incomingText) return ''; - if (!existingText) return incomingText; - - if (incomingText === existingText) return ''; - if (incomingText.startsWith(existingText)) { - return incomingText.slice(existingText.length); - } - if (existingText.endsWith(incomingText)) return ''; - - // Some upstreams emit overlapping windows instead of strict deltas. - // Keep only the non-overlapping suffix to avoid duplicated text. - const maxOverlap = Math.min(existingText.length, incomingText.length); - const MIN_OVERLAP = 8; - for (let overlap = maxOverlap; overlap >= MIN_OVERLAP; overlap -= 1) { - if (existingText.slice(-overlap) === incomingText.slice(0, overlap)) { - return incomingText.slice(overlap); - } - } - - return incomingText; -} - -function appendMessageDelta(state: ResponsesStreamState, delta: string): string[] { - if (!delta) return []; - - const events: string[] = []; - events.push(...ensureMessageItem(state)); - if (!state.messageItem) return events; - - const normalizedDelta = toIncrementalText(state.messageItem.text, delta); - if (!normalizedDelta) return events; - - state.messageItem.text += normalizedDelta; - events.push(emitResponsesEvent(state, 'response.output_text.delta', { - item_id: state.messageItem.itemId, - output_index: state.messageItem.outputIndex, - content_index: state.messageItem.contentIndex, - delta: normalizedDelta, - })); - return events; -} - -function closeMessageItem(state: ResponsesStreamState): string[] { - if (!state.messageItem) return []; - - const item = state.messageItem; - const events = [ - emitResponsesEvent(state, 'response.output_text.done', { - item_id: item.itemId, - output_index: item.outputIndex, - content_index: item.contentIndex, - text: item.text, - }), - emitResponsesEvent(state, 'response.content_part.done', { - item_id: item.itemId, - output_index: item.outputIndex, - content_index: item.contentIndex, - part: { - type: 'output_text', - text: item.text, - }, - }), - emitResponsesEvent(state, 'response.output_item.done', { - output_index: item.outputIndex, - item: { - id: item.itemId, - type: 'message', - status: 'completed', - role: 'assistant', - content: [{ - type: 'output_text', - text: item.text, - }], - }, - }), - ]; - - state.messageItem = null; - return events; -} - -function ensureToolItem( - state: ResponsesStreamState, - toolIndex: number, - id?: string, - name?: string, -): string[] { - const existing = state.toolItems.get(toolIndex); - if (existing) { - if (id && !existing.callId) existing.callId = ensureFunctionCallId(id); - if (id && !existing.itemId) existing.itemId = ensureFunctionCallId(id); - if (name && !existing.name) existing.name = name; - return []; - } - - const outputIndex = state.outputCursor; - state.outputCursor += 1; - const callId = ensureFunctionCallId(id || `${state.responseId}_${toolIndex}`); - const itemId = callId; - const toolState: ResponsesToolItemState = { - toolIndex, - itemId, - callId, - outputIndex, - name: name || '', - arguments: '', - }; - state.toolItems.set(toolIndex, toolState); - - return [ - emitResponsesEvent(state, 'response.output_item.added', { - output_index: outputIndex, - item: { - id: itemId, - type: 'function_call', - status: 'in_progress', - call_id: callId, - name: toolState.name, - arguments: '', - }, - }), - ]; -} - -function appendToolCallDelta( - state: ResponsesStreamState, - toolDelta: NonNullable[number], -): string[] { - const toolIndex = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0; - const events: string[] = []; - events.push(...ensureToolItem(state, toolIndex, toolDelta.id, toolDelta.name)); - - const toolState = state.toolItems.get(toolIndex); - if (!toolState) return events; - if (toolDelta.name && !toolState.name) toolState.name = toolDelta.name; - - if (toolDelta.argumentsDelta !== undefined) { - toolState.arguments += toolDelta.argumentsDelta; - events.push(emitResponsesEvent(state, 'response.function_call_arguments.delta', { - item_id: toolState.itemId, - output_index: toolState.outputIndex, - delta: toolDelta.argumentsDelta, - })); - } - - return events; -} - -function closeToolItems(state: ResponsesStreamState): string[] { - if (state.toolItems.size <= 0) return []; - - const ordered = Array.from(state.toolItems.values()) - .sort((a, b) => a.outputIndex - b.outputIndex); - const events: string[] = []; - for (const toolItem of ordered) { - events.push(emitResponsesEvent(state, 'response.function_call_arguments.done', { - item_id: toolItem.itemId, - output_index: toolItem.outputIndex, - arguments: toolItem.arguments, - })); - events.push(emitResponsesEvent(state, 'response.output_item.done', { - output_index: toolItem.outputIndex, - item: { - id: toolItem.itemId, - type: 'function_call', - status: 'completed', - call_id: toolItem.callId, - name: toolItem.name, - arguments: toolItem.arguments, - }, - })); - } - - state.toolItems.clear(); - return events; -} - -function completeResponsesStream( - state: ResponsesStreamState, - streamContext: { id: string; model: string; created: number }, - usage: UsageSummary, -): string[] { - if (state.completed) return []; - - const events: string[] = []; - events.push(...ensureResponsesStarted(state, streamContext)); - events.push(...closeMessageItem(state)); - events.push(...closeToolItems(state)); - events.push(emitResponsesEvent(state, 'response.completed', { - response: buildResponseObject(state, 'completed', usage), - })); - events.push('data: [DONE]\n\n'); - state.completed = true; - return events; -} - -function failResponsesStream( - state: ResponsesStreamState, - streamContext: { id: string; model: string; created: number }, - usage: UsageSummary, - payload?: unknown, -): string[] { - if (state.completed) return []; - - const events: string[] = []; - events.push(...ensureResponsesStarted(state, streamContext)); - - const responsePayload = isRecord(payload) && isRecord((payload as any).response) - ? ((payload as any).response as Record) - : null; - const errorPayload = isRecord(payload) && isRecord((payload as any).error) - ? ((payload as any).error as Record) - : null; - - if (responsePayload && asTrimmedString(responsePayload.id)) { - state.responseId = ensureResponseId(asTrimmedString(responsePayload.id)); - } - if (responsePayload && asTrimmedString(responsePayload.model)) { - state.model = asTrimmedString(responsePayload.model); - } - - const failedResponse = buildResponseObject(state, 'failed', usage); - if (errorPayload) { - failedResponse.error = errorPayload; - } - - events.push(emitResponsesEvent(state, 'response.failed', { - response: failedResponse, - })); - events.push('data: [DONE]\n\n'); - state.completed = true; - return events; -} - -function serializeConvertedResponsesEvents(input: { - state: ResponsesStreamState; - streamContext: { id: string; model: string; created: number }; - event: NormalizedStreamEvent; - usage: UsageSummary; -}): string[] { - const { state, streamContext, event, usage } = input; - if (state.completed) return []; - - const shouldStart = ( - event.role === 'assistant' - || !!event.contentDelta - || !!event.reasoningDelta - || (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) - || !!event.done - || !!event.finishReason - ); - - const events: string[] = []; - if (shouldStart) { - events.push(...ensureResponsesStarted(state, streamContext)); - } - - if (event.contentDelta) { - events.push(...appendMessageDelta(state, event.contentDelta)); - } - if (event.reasoningDelta) { - events.push(...appendMessageDelta(state, event.reasoningDelta)); - } - - if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { - events.push(...closeMessageItem(state)); - for (const toolDelta of event.toolCallDeltas) { - events.push(...appendToolCallDelta(state, toolDelta)); - } - } - - if (event.finishReason) { - // Keep compatibility with clients that expect finish before completed. - } - - if (event.done) { - events.push(...completeResponsesStream(state, streamContext, usage)); - } - - return events; -} - export async function responsesProxyRoute(app: FastifyInstance) { const handleResponsesRequest = async ( request: FastifyRequest, @@ -1374,15 +491,15 @@ export async function responsesProxyRoute(app: FastifyInstance) { let sseBuffer = ''; const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses'; - const streamContext = createStreamTransformContext(modelName); - const responsesState = createResponsesStreamState(modelName); + const streamContext = openAiResponsesTransformer.createStreamContext(modelName); + const responsesState = createOpenAiResponsesAggregateState(modelName); const writeLines = (lines: string[]) => { for (const line of lines) reply.raw.write(line); }; const consumeSseBuffer = (incoming: string): string => { - const pulled = pullSseEventsWithDone(incoming); + const pulled = openAiResponsesTransformer.pullSseEvents(incoming); for (const eventBlock of pulled.events) { if (eventBlock.data === '[DONE]') { if (passthroughResponsesStream) { @@ -1425,7 +542,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { } if (parsedPayload && typeof parsedPayload === 'object') { - const normalizedEvent = normalizeUpstreamStreamEvent(parsedPayload, streamContext, modelName); + const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName); writeLines(serializeConvertedResponsesEvents({ state: responsesState, streamContext, @@ -1510,12 +627,16 @@ export async function responsesProxyRoute(app: FastifyInstance) { } const latency = Date.now() - startTime; const parsedUsage = parseProxyUsage(upstreamData); - const normalized = normalizeUpstreamFinalResponse( + const normalized = openAiResponsesTransformer.transformFinalResponse( upstreamData, modelName, rawText, ); - const downstreamData = toResponsesPayload(upstreamData, normalized, parsedUsage); + const downstreamData = serializeResponsesFinalPayload({ + upstreamPayload: upstreamData, + normalized, + usage: parsedUsage, + }); const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, diff --git a/src/server/routes/proxy/router.ts b/src/server/routes/proxy/router.ts index 6eb3bf3..d1a7aa4 100644 --- a/src/server/routes/proxy/router.ts +++ b/src/server/routes/proxy/router.ts @@ -6,6 +6,9 @@ import { embeddingsProxyRoute } from './embeddings.js'; import { completionsProxyRoute } from './completions.js'; import { responsesProxyRoute } from './responses.js'; import { imagesProxyRoute } from './images.js'; +import { searchProxyRoute } from './search.js'; +import { geminiProxyRoute } from './gemini.js'; +import { videosProxyRoute } from './videos.js'; export async function proxyRoutes(app: FastifyInstance) { // Auth middleware for all /v1 routes @@ -19,5 +22,8 @@ export async function proxyRoutes(app: FastifyInstance) { await app.register(responsesProxyRoute); await app.register(modelsProxyRoute); await app.register(embeddingsProxyRoute); + await app.register(searchProxyRoute); await app.register(imagesProxyRoute); + await app.register(videosProxyRoute); + await app.register(geminiProxyRoute); } diff --git a/src/server/routes/proxy/search.test.ts b/src/server/routes/proxy/search.test.ts new file mode 100644 index 0000000..ebfc17a --- /dev/null +++ b/src/server/routes/proxy/search.test.ts @@ -0,0 +1,197 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const estimateProxyCostMock = vi.fn(async () => 0); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: () => ({ + run: () => undefined, + }), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: (arg: any) => estimateProxyCostMock(arg), +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + schema: { + proxyLogs: {}, + }, +})); + +describe('/v1/search route', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { searchProxyRoute } = await import('./search.js'); + app = Fastify(); + await app.register(searchProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + estimateProxyCostMock.mockClear(); + dbInsertMock.mockClear(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: '__search', + }); + selectNextChannelMock.mockReturnValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('defaults model to __search and forwards to the upstream /v1/search endpoint', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + object: 'search.result', + data: [{ title: 'AxonHub' }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/search', + headers: { + authorization: 'Bearer sk-demo', + }, + payload: { + query: 'axonhub', + }, + }); + + expect(response.statusCode).toBe(200); + expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything()); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://upstream.example.com/v1/search'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + query: 'axonhub', + max_results: 10, + model: '__search', + }); + }); + + it('rejects max_results outside the allowed range', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/search', + headers: { + authorization: 'Bearer sk-demo', + }, + payload: { + query: 'axonhub', + max_results: 21, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: { + message: 'max_results must be an integer between 1 and 20', + type: 'invalid_request_error', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects max_results below the allowed range', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/search', + headers: { + authorization: 'Bearer sk-demo', + }, + payload: { + query: 'axonhub', + max_results: 0, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: { + message: 'max_results must be an integer between 1 and 20', + type: 'invalid_request_error', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects streaming search requests', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/search', + headers: { + authorization: 'Bearer sk-demo', + }, + payload: { + query: 'axonhub', + stream: true, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: { + message: 'search does not support streaming', + type: 'invalid_request_error', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts new file mode 100644 index 0000000..2f04ca9 --- /dev/null +++ b/src/server/routes/proxy/search.ts @@ -0,0 +1,180 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { fetch } from 'undici'; +import { db, schema } from '../../db/index.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { composeProxyLogMessage } from './logPathMeta.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; + +const MAX_RETRIES = 2; +const DEFAULT_SEARCH_MODEL = '__search'; +const DEFAULT_MAX_RESULTS = 10; +const MAX_MAX_RESULTS = 20; + +export async function searchProxyRoute(app: FastifyInstance) { + app.post('/v1/search', async (request: FastifyRequest, reply: FastifyReply) => { + const body = (request.body || {}) as Record; + const query = typeof body.query === 'string' ? body.query.trim() : ''; + if (!query) { + return reply.code(400).send({ + error: { message: 'query is required', type: 'invalid_request_error' }, + }); + } + if (body.stream === true) { + return reply.code(400).send({ + error: { message: 'search does not support streaming', type: 'invalid_request_error' }, + }); + } + const rawMaxResults = body.max_results; + const maxResults = rawMaxResults == null + ? DEFAULT_MAX_RESULTS + : (typeof rawMaxResults === 'number' && Number.isInteger(rawMaxResults) + ? rawMaxResults + : NaN); + if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > MAX_MAX_RESULTS) { + return reply.code(400).send({ + error: { + message: `max_results must be an integer between 1 and ${MAX_MAX_RESULTS}`, + type: 'invalid_request_error', + }, + }); + } + + const requestedModel = typeof body.model === 'string' && body.model.trim().length > 0 + ? body.model.trim() + : DEFAULT_SEARCH_MODEL; + + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= MAX_RETRIES) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + const targetUrl = `${selected.site.url}/v1/search`; + const forwardBody = { + ...body, + max_results: maxResults, + model: selected.actualModel || requestedModel, + }; + const startTime = Date.now(); + + try { + const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + })); + + const text = await upstream.text(); + if (!upstream.ok) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); + if (isTokenExpiredError({ status: upstream.status, message: text })) { + await reportTokenExpired({ + accountId: selected.account.id, + username: selected.account.username, + siteName: selected.site.name, + detail: `HTTP ${upstream.status}`, + }); + } + if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: `upstream returned HTTP ${upstream.status}`, + }); + return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); + } + + let data: any = {}; + try { data = JSON.parse(text); } catch { data = { data: [] }; } + + const latency = Date.now() - startTime; + tokenRouter.recordSuccess(selected.channel.id, latency, 0); + recordDownstreamCostUsage(request, 0); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount); + return reply.code(upstream.status).send(data); + } catch (error: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: error?.message || 'network failure', + }); + return reply.code(502).send({ + error: { message: error?.message || 'network failure', type: 'upstream_error' }, + }); + } + } + }); +} + +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + estimatedCost: 0, + errorMessage: composeProxyLogMessage({ + downstreamPath: '/v1/search', + errorMessage, + }), + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/search] failed to write proxy log', error); + } +} diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 2d2d113..045b6f6 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -313,6 +313,84 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); + it('preserves multimodal user content when converting chat to messages', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'messages', + modelName: 'claude-3-7-sonnet', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'anthropic', + siteUrl: 'https://example.com', + openaiBody: { + model: 'gpt-4o-mini', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + ], + }, + ], + }, + downstreamFormat: 'openai', + }); + + expect(request.path).toBe('/v1/messages'); + expect(request.body.messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { + type: 'image', + cache_control: { type: 'ephemeral' }, + source: { + type: 'url', + url: 'https://example.com/cat.png', + }, + }, + ], + }, + ]); + }); + + it('preserves multimodal user content when converting chat to responses', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-4.1', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'openai', + siteUrl: 'https://example.com', + openaiBody: { + model: 'gpt-4o-mini', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + ], + }, + ], + }, + downstreamFormat: 'openai', + }); + + expect(request.path).toBe('/v1/responses'); + expect(request.body.input).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'describe this' }, + { type: 'input_image', image_url: { url: 'https://example.com/cat.png' } }, + ], + }, + ]); + }); + it('applies global responses standardization and drops non-standard fields', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'responses', @@ -419,4 +497,283 @@ describe('buildUpstreamEndpointRequest', () => { expect(request.headers['content-type']).toBeUndefined(); expect(request.headers['Content-Type']).toBe('application/json'); }); + + it('preserves structured responses content blocks instead of flattening them', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'upstream-gpt', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'openai', + siteUrl: 'https://example.com', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'describe this image' }, + { type: 'input_image', image_url: 'https://example.com/cat.png' }, + { + type: 'input_audio', + input_audio: { + data: 'UklGRg==', + format: 'wav', + }, + }, + ], + }, + ], + }, + }); + + expect(request.path).toBe('/v1/responses'); + expect(request.body.input).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'describe this image' }, + { type: 'input_image', image_url: 'https://example.com/cat.png' }, + { + type: 'input_audio', + input_audio: { + data: 'UklGRg==', + format: 'wav', + }, + }, + ], + }, + ]); + }); + + it('preserves multimodal OpenAI user content when converting to Anthropic messages', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'messages', + modelName: 'claude-sonnet-4-5', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'claude', + siteUrl: 'https://example.com', + downstreamFormat: 'openai', + openaiBody: { + model: 'claude-sonnet-4-5', + messages: [ + { + role: 'system', + content: 'be careful', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'describe this image' }, + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + ], + }, + ], + }, + }); + + expect(request.path).toBe('/v1/messages'); + expect(request.body.system).toBe('be careful'); + expect(request.body.messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'describe this image' }, + { + type: 'image', + cache_control: { type: 'ephemeral' }, + source: { + type: 'url', + url: 'https://example.com/cat.png', + }, + }, + ], + }, + ]); + }); + + it('preserves assistant reasoning blocks for Anthropic and strips cache_control from thinking blocks', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'messages', + modelName: 'claude-sonnet-4-5', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'claude', + siteUrl: 'https://example.com', + downstreamFormat: 'openai', + openaiBody: { + model: 'claude-sonnet-4-5', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'reasoning', + text: 'internal thinking', + cache_control: { type: 'ephemeral' }, + }, + { + type: 'text', + text: 'final answer', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + }, + }); + + expect(request.path).toBe('/v1/messages'); + expect(request.body.messages).toEqual([ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'internal thinking', + }, + { + type: 'text', + text: 'final answer', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + + it('preserves multimodal OpenAI user content when converting to Responses input', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.2', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'openai', + siteUrl: 'https://example.com', + downstreamFormat: 'openai', + openaiBody: { + model: 'gpt-5.2', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'describe the upload' }, + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + ], + }, + ], + }, + }); + + expect(request.path).toBe('/v1/responses'); + expect(request.body.input).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'describe the upload' }, + { type: 'input_image', image_url: { url: 'https://example.com/cat.png' } }, + ], + }, + ]); + }); + + it('preserves Anthropic image and tool_result blocks instead of flattening to plain text', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'claude', + siteUrl: 'https://example.com', + openaiBody: { + model: 'gpt-5.2', + messages: [ + { + role: 'system', + content: 'system prompt', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'look at this' }, + { type: 'image_url', image_url: 'https://example.com/cat.png' }, + ], + }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup', + arguments: '{"topic":"cat"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: '{"ok":true}', + }, + { + role: 'user', + content: 'thanks', + }, + ], + }, + downstreamFormat: 'openai', + }); + + expect(request.path).toBe('/v1/messages'); + expect(request.body.system).toBe('system prompt'); + expect(request.body.messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'look at this' }, + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/cat.png', + }, + }, + ], + }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: { topic: 'cat' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_1', + content: '{"ok":true}', + }, + { + type: 'text', + text: 'thanks', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); }); diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 3ccfe2e..a6c1093 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -1,5 +1,13 @@ import { fetchModelPricingCatalog } from '../../services/modelPricingService.js'; -import type { DownstreamFormat } from './chatFormats.js'; +import type { DownstreamFormat } from '../../transformers/shared/normalized.js'; +import { + convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer, + sanitizeResponsesBodyForProxy as sanitizeResponsesBodyForProxyViaTransformer, +} from '../../transformers/openai/responses/conversion.js'; +import { + convertOpenAiBodyToAnthropicMessagesBody, + sanitizeAnthropicMessagesBody, +} from '../../transformers/anthropic/messages/conversion.js'; export type UpstreamEndpoint = 'chat' | 'messages' | 'responses'; export type EndpointPreference = DownstreamFormat | 'responses'; @@ -170,660 +178,6 @@ function toFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } -function sanitizeMessagesBodyForAnthropic(body: Record): Record { - const sanitized: Record = { ...body }; - const hasTemperature = toFiniteNumber(sanitized.temperature) !== null; - const hasTopP = toFiniteNumber(sanitized.top_p) !== null; - // Some Anthropic-compatible upstreams reject requests carrying both fields. - 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; -} - -function normalizeContentText(content: unknown): string { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .map((item) => { - if (typeof item === 'string') return item; - if (!isRecord(item)) return ''; - const text = asTrimmedString(item.text ?? item.content ?? item.output_text); - return text; - }) - .filter((item) => item.length > 0) - .join('\n'); - } - if (isRecord(content)) { - const text = asTrimmedString(content.text ?? content.content ?? content.output_text); - return text; - } - return ''; -} - -function safeJsonStringify(value: unknown): string { - try { - return JSON.stringify(value); - } catch { - return ''; - } -} - -function parseJsonString(raw: string): unknown { - const trimmed = raw.trim(); - if (!trimmed) return {}; - try { - return JSON.parse(trimmed); - } catch { - return { value: raw }; - } -} - -function normalizeOpenAiToolArguments(raw: unknown): string { - if (typeof raw === 'string') return raw; - if (raw === undefined || raw === null) return ''; - if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); - if (Array.isArray(raw) || isRecord(raw)) { - return safeJsonStringify(raw); - } - return ''; -} - -function normalizeAnthropicToolInput(raw: unknown): unknown { - if (raw === undefined || raw === null) return {}; - if (isRecord(raw) || Array.isArray(raw)) return raw; - if (typeof raw === 'string') return parseJsonString(raw); - if (typeof raw === 'number' || typeof raw === 'boolean') return raw; - return {}; -} - -function normalizeToolMessageContent(raw: unknown): string { - const contentText = normalizeContentText(raw).trim(); - if (contentText) return contentText; - - if (raw === undefined || raw === null) return ''; - if (typeof raw === 'string') return raw; - if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); - if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw); - return ''; -} - -function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown { - if (!Array.isArray(rawTools)) return rawTools; - - const converted = rawTools - .map((item) => { - if (!isRecord(item)) return null; - - const type = asTrimmedString(item.type).toLowerCase(); - if (type === 'function' && isRecord(item.function)) { - const fn = item.function; - const name = asTrimmedString(fn.name); - if (!name) return null; - - const mapped: Record = { name }; - const description = asTrimmedString(fn.description); - if (description) mapped.description = description; - if (fn.parameters !== undefined) mapped.input_schema = fn.parameters; - if (item.cache_control !== undefined) mapped.cache_control = item.cache_control; - return mapped; - } - - if (asTrimmedString(item.name) && item.input_schema !== undefined) { - return item; - } - - return null; - }) - .filter((item): item is Record => !!item); - - return converted.length > 0 ? converted : rawTools; -} - -function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown { - if (rawToolChoice === undefined) return undefined; - - if (typeof rawToolChoice === 'string') { - const normalized = rawToolChoice.trim().toLowerCase(); - if (normalized === 'required') return { type: 'any' }; - if (normalized === 'none') return { type: 'none' }; - if (normalized === 'auto') return { type: 'auto' }; - if (normalized === 'any') return { type: 'any' }; - return rawToolChoice; - } - - if (!isRecord(rawToolChoice)) return rawToolChoice; - - const type = asTrimmedString(rawToolChoice.type).toLowerCase(); - if (type === 'function' && isRecord(rawToolChoice.function)) { - const name = asTrimmedString(rawToolChoice.function.name); - return name ? { type: 'tool', name } : { type: 'any' }; - } - - if (type === 'tool' || type === 'auto' || type === 'any' || type === 'none') { - return rawToolChoice; - } - - return rawToolChoice; -} - -function convertOpenAiToolsToResponses(rawTools: unknown): unknown { - if (!Array.isArray(rawTools)) return rawTools; - - const converted = rawTools - .map((item) => { - if (!isRecord(item)) return null; - - const type = asTrimmedString(item.type).toLowerCase(); - if (type === 'function' && isRecord(item.function)) { - const fn = item.function; - const name = asTrimmedString(fn.name); - if (!name) return null; - - const mapped: Record = { - type: 'function', - name, - }; - const description = asTrimmedString(fn.description); - if (description) mapped.description = description; - if (fn.parameters !== undefined) mapped.parameters = fn.parameters; - if (fn.strict !== undefined) mapped.strict = fn.strict; - return mapped; - } - - if (type === 'function' && asTrimmedString(item.name)) { - return item; - } - - if (type === 'image_generation') { - return item; - } - - return null; - }) - .filter((item): item is Record => !!item); - - return converted.length > 0 ? converted : rawTools; -} - -function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown { - if (rawToolChoice === undefined) return undefined; - if (typeof rawToolChoice === 'string') return rawToolChoice; - if (!isRecord(rawToolChoice)) return rawToolChoice; - - const type = asTrimmedString(rawToolChoice.type).toLowerCase(); - if (type === 'function' && isRecord(rawToolChoice.function)) { - const name = asTrimmedString(rawToolChoice.function.name); - if (!name) return 'required'; - return { type: 'function', name }; - } - - return rawToolChoice; -} - -function toResponsesInputMessageFromText(text: string): Record { - return { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text }], - }; -} - -function normalizeResponsesMessageItem(item: Record): Record { - const type = asTrimmedString(item.type).toLowerCase(); - if (type === 'function_call' || type === 'function_call_output') { - return item; - } - - const role = asTrimmedString(item.role).toLowerCase(); - const textContent = normalizeContentText(item.content ?? item.text).trim(); - - if (type === 'message') { - if (!textContent) return item; - const normalizedRole = role || 'user'; - const textType = normalizedRole === 'assistant' ? 'output_text' : 'input_text'; - return { - ...item, - role: normalizedRole, - content: [{ type: textType, text: textContent }], - }; - } - - if (role) { - if (!textContent) return item; - const textType = role === 'assistant' ? 'output_text' : 'input_text'; - return { - type: 'message', - role, - content: [{ type: textType, text: textContent }], - }; - } - - if (textContent) { - return toResponsesInputMessageFromText(textContent); - } - - return item; -} - -function normalizeResponsesInputForCompatibility(input: unknown): unknown { - if (typeof input === 'string') { - const normalized = input.trim(); - if (!normalized) return input; - return [toResponsesInputMessageFromText(normalized)]; - } - - if (Array.isArray(input)) { - return input.map((item) => { - if (typeof item === 'string') { - const normalized = item.trim(); - return normalized ? toResponsesInputMessageFromText(normalized) : item; - } - if (!isRecord(item)) return item; - return normalizeResponsesMessageItem(item); - }); - } - - if (isRecord(input)) { - return [normalizeResponsesMessageItem(input)]; - } - - return input; -} - -function normalizeResponsesBodyForCompatibility( - body: Record, -): Record { - const nextInput = normalizeResponsesInputForCompatibility(body.input); - if (nextInput === body.input) return body; - return { - ...body, - input: nextInput, - }; -} - -const ALLOWED_RESPONSES_FIELDS = new Set([ - 'model', - 'input', - 'instructions', - 'max_output_tokens', - 'max_completion_tokens', - 'temperature', - 'top_p', - 'truncation', - 'tools', - 'tool_choice', - 'parallel_tool_calls', - 'metadata', - 'reasoning', - 'store', - 'stream', - 'user', - 'previous_response_id', - 'text', - 'audio', - 'include', - 'response_format', - 'service_tier', - 'stop', - 'n', -]); - -function sanitizeResponsesBodyForProxy( - body: Record, - modelName: string, - stream: boolean, -): Record { - let normalized = normalizeResponsesBodyForCompatibility({ - ...body, - model: modelName, - stream, - }); - - if (normalized.input === undefined) { - if (Array.isArray((normalized as Record).messages)) { - const converted = convertOpenAiBodyToResponsesBody(normalized, modelName, stream); - normalized = normalizeResponsesBodyForCompatibility(converted); - } else { - const prompt = asTrimmedString((normalized as Record).prompt); - if (prompt) { - normalized = { - ...normalized, - input: [toResponsesInputMessageFromText(prompt)], - }; - } - } - } - - const sanitized: Record = {}; - for (const [key, value] of Object.entries(normalized)) { - if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue; - if (key === 'max_completion_tokens') continue; - sanitized[key] = value; - } - - const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens); - if (maxOutputTokens !== null && maxOutputTokens > 0) { - sanitized.max_output_tokens = Math.trunc(maxOutputTokens); - } else { - const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens); - if (maxCompletionTokens !== null && maxCompletionTokens > 0) { - sanitized.max_output_tokens = Math.trunc(maxCompletionTokens); - } - } - - sanitized.model = modelName; - sanitized.stream = stream; - return sanitized; -} - -function convertOpenAiBodyToMessagesBody( - openaiBody: Record, - modelName: string, - stream: boolean, -): Record { - const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; - const systemContents: string[] = []; - const messages: Array<{ - role: 'user' | 'assistant'; - content: string | Array>; - }> = []; - - const appendAssistantMessage = (item: Record) => { - const role = asTrimmedString(item.role).toLowerCase() || 'assistant'; - if (role !== 'assistant') return; - - const contentBlocks: Array> = []; - - const textContent = normalizeContentText(item.content).trim(); - if (textContent) { - contentBlocks.push({ - type: 'text', - text: textContent, - }); - } - - const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; - for (let index = 0; index < rawToolCalls.length; index += 1) { - const toolCall = rawToolCalls[index]; - if (!isRecord(toolCall)) continue; - const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; - - const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`; - const name = ( - asTrimmedString(functionPart.name) - || asTrimmedString(toolCall.name) - || `tool_${index}` - ); - const input = normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments); - - contentBlocks.push({ - type: 'tool_use', - id, - name, - input, - }); - } - - if (contentBlocks.length <= 0) return; - - if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') { - const singleText = asTrimmedString(contentBlocks[0].text); - if (singleText) { - messages.push({ - role: 'assistant', - content: singleText, - }); - } - return; - } - - messages.push({ - role: 'assistant', - content: contentBlocks, - }); - }; - - for (let messageIndex = 0; messageIndex < rawMessages.length; messageIndex += 1) { - const item = rawMessages[messageIndex]; - if (!isRecord(item)) continue; - const role = asTrimmedString(item.role).toLowerCase() || 'user'; - - if (role === 'system' || role === 'developer') { - const content = normalizeContentText(item.content); - if (!content) continue; - systemContents.push(content); - continue; - } - - if (role === 'tool') { - const toolResultBlocks: Array> = []; - let cursor = messageIndex; - while (cursor < rawMessages.length) { - const toolCandidate = rawMessages[cursor]; - if (!isRecord(toolCandidate)) break; - const toolRole = asTrimmedString(toolCandidate.role).toLowerCase(); - if (toolRole !== 'tool') break; - - const toolUseId = ( - asTrimmedString(toolCandidate.tool_call_id) - || asTrimmedString(toolCandidate.id) - ); - const toolResultContent = normalizeToolMessageContent(toolCandidate.content).trim(); - if (toolUseId && toolResultContent) { - toolResultBlocks.push({ - type: 'tool_result', - tool_use_id: toolUseId, - content: toolResultContent, - }); - } - cursor += 1; - } - - if (toolResultBlocks.length > 0 && cursor < rawMessages.length) { - const nextItem = rawMessages[cursor]; - if (isRecord(nextItem)) { - const nextRole = asTrimmedString(nextItem.role).toLowerCase(); - if (nextRole === 'user') { - const userText = normalizeContentText(nextItem.content).trim(); - if (userText) { - toolResultBlocks.push({ - type: 'text', - text: userText, - }); - } - cursor += 1; - } - } - } - - if (toolResultBlocks.length > 0) { - messages.push({ - role: 'user', - content: toolResultBlocks, - }); - } - - messageIndex = cursor - 1; - continue; - } - - if (role === 'assistant') { - appendAssistantMessage(item); - continue; - } - - const content = normalizeContentText(item.content).trim(); - if (!content) continue; - - messages.push({ - role: 'user', - content, - }); - } - - const body: Record = { - model: modelName, - stream, - messages, - max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096, - }; - - if (systemContents.length > 0) { - body.system = systemContents.join('\n\n'); - } - - const temperature = toFiniteNumber(openaiBody.temperature); - if (temperature !== null) body.temperature = temperature; - - const topP = toFiniteNumber(openaiBody.top_p); - if (topP !== null) body.top_p = topP; - - if (Array.isArray(openaiBody.stop) && openaiBody.stop.length > 0) { - body.stop_sequences = openaiBody.stop; - } - - if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools); - - const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice); - if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice; - - return body; -} - -function convertOpenAiBodyToResponsesBody( - openaiBody: Record, - modelName: string, - stream: boolean, -): Record { - const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; - const systemContents: string[] = []; - const inputItems: Array> = []; - - for (const item of rawMessages) { - if (!isRecord(item)) continue; - const role = asTrimmedString(item.role).toLowerCase() || 'user'; - - if (role === 'system' || role === 'developer') { - const content = normalizeContentText(item.content).trim(); - if (!content) continue; - systemContents.push(content); - continue; - } - - if (role === 'assistant') { - const textContent = normalizeContentText(item.content).trim(); - if (textContent) { - inputItems.push({ - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: textContent }], - }); - } - - const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; - for (let index = 0; index < rawToolCalls.length; index += 1) { - const toolCall = rawToolCalls[index]; - if (!isRecord(toolCall)) continue; - const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; - const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`; - const name = ( - asTrimmedString(functionPart.name) - || asTrimmedString(toolCall.name) - || `tool_${index}` - ); - const argumentsValue = normalizeOpenAiToolArguments( - functionPart.arguments ?? toolCall.arguments, - ); - - inputItems.push({ - type: 'function_call', - call_id: callId, - name, - arguments: argumentsValue, - }); - } - continue; - } - - if (role === 'tool') { - const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id); - if (!callId) continue; - - inputItems.push({ - type: 'function_call_output', - call_id: callId, - output: normalizeToolMessageContent(item.content), - }); - continue; - } - - const content = normalizeContentText(item.content).trim(); - if (!content) continue; - inputItems.push({ - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: content }], - }); - } - - const maxOutputTokens = ( - toFiniteNumber(openaiBody.max_output_tokens) - ?? toFiniteNumber(openaiBody.max_completion_tokens) - ?? toFiniteNumber(openaiBody.max_tokens) - ?? 4096 - ); - - const body: Record = { - model: modelName, - stream, - max_output_tokens: maxOutputTokens, - input: inputItems, - }; - - if (systemContents.length > 0) { - body.instructions = systemContents.join('\n\n'); - } - - const temperature = toFiniteNumber(openaiBody.temperature); - if (temperature !== null) body.temperature = temperature; - - const topP = toFiniteNumber(openaiBody.top_p); - if (topP !== null) body.top_p = topP; - - if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata; - if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning; - if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls; - - if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools); - - const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice); - if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice; - - return normalizeResponsesBodyForCompatibility(body); -} function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] { const raw = asTrimmedString(value).toLowerCase(); @@ -1104,9 +458,9 @@ export function buildUpstreamEndpointRequest(input: { model: input.modelName, stream: input.stream, } - : convertOpenAiBodyToMessagesBody(input.openaiBody, input.modelName, input.stream) + : convertOpenAiBodyToAnthropicMessagesBody(input.openaiBody, input.modelName, input.stream) ); - const sanitizedBody = sanitizeMessagesBodyForAnthropic(body); + const sanitizedBody = sanitizeAnthropicMessagesBody(body); const headers = ensureStreamAcceptHeader({ ...commonHeaders, @@ -1133,9 +487,9 @@ export function buildUpstreamEndpointRequest(input: { model: input.modelName, stream: input.stream, } - : convertOpenAiBodyToResponsesBody(input.openaiBody, input.modelName, input.stream) + : convertOpenAiBodyToResponsesBodyViaTransformer(input.openaiBody, input.modelName, input.stream) ); - const body = sanitizeResponsesBodyForProxy(rawBody, input.modelName, input.stream); + const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); const headers = ensureStreamAcceptHeader({ ...commonHeaders, @@ -1291,3 +645,4 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str ) ); } + diff --git a/src/server/routes/proxy/videos.test.ts b/src/server/routes/proxy/videos.test.ts new file mode 100644 index 0000000..8394da7 --- /dev/null +++ b/src/server/routes/proxy/videos.test.ts @@ -0,0 +1,248 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const estimateProxyCostMock = vi.fn(async () => 0); +const saveProxyVideoTaskMock = vi.fn(); +const getProxyVideoTaskByPublicIdMock = vi.fn(); +const deleteProxyVideoTaskByPublicIdMock = vi.fn(); +const refreshProxyVideoTaskSnapshotMock = vi.fn(); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: (arg: any) => estimateProxyCostMock(arg), +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyVideoTaskStore.js', () => ({ + saveProxyVideoTask: (...args: unknown[]) => saveProxyVideoTaskMock(...args), + getProxyVideoTaskByPublicId: (...args: unknown[]) => getProxyVideoTaskByPublicIdMock(...args), + deleteProxyVideoTaskByPublicId: (...args: unknown[]) => deleteProxyVideoTaskByPublicIdMock(...args), + refreshProxyVideoTaskSnapshot: (...args: unknown[]) => refreshProxyVideoTaskSnapshotMock(...args), +})); + +describe('/v1/videos routes', () => { + let app: FastifyInstance; + + const buildMultipartBody = (boundary: string) => Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="model"\r\n\r\n` + + `sora-2\r\n` + + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="prompt"\r\n\r\n` + + `a cat walking\r\n` + + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="input_reference"; filename="cat.png"\r\n` + + `Content-Type: image/png\r\n\r\n` + + `pngdata\r\n` + + `--${boundary}--\r\n`, + ); + + beforeAll(async () => { + const { videosProxyRoute } = await import('./videos.js'); + app = Fastify(); + await app.register(videosProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + estimateProxyCostMock.mockClear(); + saveProxyVideoTaskMock.mockReset(); + getProxyVideoTaskByPublicIdMock.mockReset(); + deleteProxyVideoTaskByPublicIdMock.mockReset(); + refreshProxyVideoTaskSnapshotMock.mockReset(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'sora-2', + }); + selectNextChannelMock.mockReturnValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('creates an upstream video task and stores a local public id mapping', async () => { + saveProxyVideoTaskMock.mockResolvedValue({ + publicId: 'vid_local_123', + upstreamVideoId: 'vid_upstream_123', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'vid_upstream_123', + object: 'video', + status: 'queued', + model: 'sora-2', + prompt: 'a cat walking', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/videos', + payload: { + model: 'sora-2', + prompt: 'a cat walking', + }, + }); + + expect(response.statusCode).toBe(200); + expect(saveProxyVideoTaskMock).toHaveBeenCalledWith(expect.objectContaining({ + upstreamVideoId: 'vid_upstream_123', + requestedModel: 'sora-2', + actualModel: 'sora-2', + lastUpstreamStatus: 200, + statusSnapshot: expect.objectContaining({ + id: 'vid_upstream_123', + status: 'queued', + }), + })); + expect(response.json()).toMatchObject({ + id: 'vid_local_123', + object: 'video', + status: 'queued', + }); + }); + + it('accepts multipart video create requests', async () => { + saveProxyVideoTaskMock.mockResolvedValue({ + publicId: 'vid_local_456', + upstreamVideoId: 'vid_upstream_456', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'vid_upstream_456', + object: 'video', + status: 'queued', + model: 'sora-2', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const boundary = 'metapi-video-boundary'; + const response = await app.inject({ + method: 'POST', + url: '/v1/videos', + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: buildMultipartBody(boundary), + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + id: 'vid_local_456', + object: 'video', + status: 'queued', + }); + }); + + it('resolves local video ids back to the upstream task on GET', async () => { + getProxyVideoTaskByPublicIdMock.mockResolvedValue({ + publicId: 'vid_local_123', + upstreamVideoId: 'vid_upstream_123', + siteUrl: 'https://upstream.example.com', + tokenValue: 'sk-demo', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'vid_upstream_123', + object: 'video', + status: 'running', + model: 'sora-2', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'GET', + url: '/v1/videos/vid_local_123', + }); + + expect(response.statusCode).toBe(200); + const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://upstream.example.com/v1/videos/vid_upstream_123'); + expect(refreshProxyVideoTaskSnapshotMock).toHaveBeenCalledWith('vid_local_123', expect.objectContaining({ + lastUpstreamStatus: 200, + statusSnapshot: expect.objectContaining({ + id: 'vid_upstream_123', + status: 'running', + }), + })); + expect(response.json()).toMatchObject({ + id: 'vid_local_123', + object: 'video', + status: 'running', + }); + }); + + it('deletes the upstream task and local mapping on DELETE', async () => { + getProxyVideoTaskByPublicIdMock.mockResolvedValue({ + publicId: 'vid_local_123', + upstreamVideoId: 'vid_upstream_123', + siteUrl: 'https://upstream.example.com', + tokenValue: 'sk-demo', + }); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + const response = await app.inject({ + method: 'DELETE', + url: '/v1/videos/vid_local_123', + }); + + expect(response.statusCode).toBe(204); + expect(deleteProxyVideoTaskByPublicIdMock).toHaveBeenCalledWith('vid_local_123'); + }); +}); diff --git a/src/server/routes/proxy/videos.ts b/src/server/routes/proxy/videos.ts new file mode 100644 index 0000000..95feaa7 --- /dev/null +++ b/src/server/routes/proxy/videos.ts @@ -0,0 +1,229 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { fetch } from 'undici'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +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 { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; +import { + deleteProxyVideoTaskByPublicId, + getProxyVideoTaskByPublicId, + refreshProxyVideoTaskSnapshot, + saveProxyVideoTask, +} from '../../services/proxyVideoTaskStore.js'; + +const MAX_RETRIES = 2; + +function rewriteVideoResponsePublicId(payload: unknown, publicId: string): unknown { + if (!payload || typeof payload !== 'object') return payload; + return { + ...(payload as Record), + id: publicId, + }; +} + +export async function videosProxyRoute(app: FastifyInstance) { + ensureMultipartBufferParser(app); + + app.post('/v1/videos', async (request: FastifyRequest, reply: FastifyReply) => { + const multipartForm = await parseMultipartFormData(request); + const jsonBody = (!multipartForm && request.body && typeof request.body === 'object') + ? request.body as Record + : null; + const requestedModel = typeof multipartForm?.get('model') === 'string' + ? String(multipartForm.get('model')).trim() + : (typeof jsonBody?.model === 'string' ? jsonBody.model.trim() : ''); + + if (!requestedModel) { + return reply.code(400).send({ + error: { message: 'model is required', type: 'invalid_request_error' }, + }); + } + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= MAX_RETRIES) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + const targetUrl = `${selected.site.url}/v1/videos`; + const startTime = Date.now(); + + try { + const requestInit = multipartForm + ? withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + Authorization: `Bearer ${selected.tokenValue}`, + }, + body: cloneFormDataWithOverrides(multipartForm, { + model: selected.actualModel || requestedModel, + }) as any, + }) + : withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify({ + ...(jsonBody || {}), + model: selected.actualModel || requestedModel, + }), + }); + + const upstream = await fetch(targetUrl, requestInit); + const text = await upstream.text(); + if (!upstream.ok) { + tokenRouter.recordFailure(selected.channel.id); + if (isTokenExpiredError({ status: upstream.status, message: text })) { + await reportTokenExpired({ + accountId: selected.account.id, + username: selected.account.username, + siteName: selected.site.name, + detail: `HTTP ${upstream.status}`, + }); + } + if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: `upstream returned HTTP ${upstream.status}`, + }); + return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); + } + + let data: any = {}; + try { data = JSON.parse(text); } catch { data = {}; } + const upstreamVideoId = typeof data?.id === 'string' ? data.id.trim() : ''; + if (!upstreamVideoId) { + return reply.code(502).send({ + error: { message: 'Upstream video response did not include id', type: 'upstream_error' }, + }); + } + + const mapping = await saveProxyVideoTask({ + upstreamVideoId, + siteUrl: selected.site.url, + tokenValue: selected.tokenValue, + requestedModel, + actualModel: selected.actualModel || requestedModel, + channelId: typeof selected.channel.id === 'number' ? selected.channel.id : null, + accountId: typeof selected.account.id === 'number' ? selected.account.id : null, + statusSnapshot: data, + upstreamResponseMeta: { + contentType: upstream.headers.get('content-type') || 'application/json', + }, + lastUpstreamStatus: upstream.status, + }); + + const latency = Date.now() - startTime; + 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); + recordDownstreamCostUsage(request, estimatedCost); + return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId)); + } catch (error: any) { + tokenRouter.recordFailure(selected.channel.id); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: error?.message || 'network failure', + }); + return reply.code(502).send({ + error: { message: error?.message || 'network failure', type: 'upstream_error' }, + }); + } + } + }); + + app.get('/v1/videos/:id', async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + const mapping = await getProxyVideoTaskByPublicId(request.params.id); + if (!mapping) { + return reply.code(404).send({ + error: { message: 'Video task not found', type: 'not_found_error' }, + }); + } + + const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${mapping.tokenValue}`, + }, + }); + const text = await upstream.text(); + try { + const data = JSON.parse(text); + await refreshProxyVideoTaskSnapshot(mapping.publicId, { + statusSnapshot: data, + upstreamResponseMeta: { + contentType: upstream.headers.get('content-type') || 'application/json', + }, + lastUpstreamStatus: upstream.status, + }); + return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId)); + } catch { + return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text); + } + }); + + app.delete('/v1/videos/:id', async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + const mapping = await getProxyVideoTaskByPublicId(request.params.id); + if (!mapping) { + return reply.code(404).send({ + error: { message: 'Video task not found', type: 'not_found_error' }, + }); + } + + const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${mapping.tokenValue}`, + }, + }); + if (upstream.ok) { + await deleteProxyVideoTaskByPublicId(mapping.publicId); + return reply.code(upstream.status).send(); + } + + const text = await upstream.text(); + return reply.code(upstream.status).send({ + error: { message: text || 'Upstream delete failed', type: 'upstream_error' }, + }); + }); +} diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 6991c1e..cebc728 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -388,9 +388,10 @@ async function ensureSchema(client: SqlClient): Promise { `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, @@ -403,9 +404,10 @@ async function ensureSchema(client: SqlClient): Promise { `CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`, `CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`, @@ -417,9 +419,10 @@ async function ensureSchema(client: SqlClient): Promise { `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, @@ -581,13 +584,15 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.tokenRoutes) { statements.push({ table: 'token_routes', - columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'enabled', 'created_at', 'updated_at'], + columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'enabled', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNullableString(row.modelPattern), asNullableString(row.displayName), asNullableString(row.displayIcon), asNullableString(row.modelMapping), + asNullableString(row.decisionSnapshot), + asNullableString(row.decisionRefreshedAt), asBoolean(row.enabled, true), asNullableString(row.createdAt), asNullableString(row.updatedAt), diff --git a/src/server/services/defaultSiteSeedService.test.ts b/src/server/services/defaultSiteSeedService.test.ts new file mode 100644 index 0000000..27f75c1 --- /dev/null +++ b/src/server/services/defaultSiteSeedService.test.ts @@ -0,0 +1,129 @@ +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 { eq } from 'drizzle-orm'; + +type DbModule = typeof import('../db/index.js'); +type SeedModule = typeof import('./defaultSiteSeedService.js'); + +describe('defaultSiteSeedService', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let ensureDefaultSitesSeeded: SeedModule['ensureDefaultSitesSeeded']; + let DEFAULT_SITE_SEED_SETTING_KEY: SeedModule['DEFAULT_SITE_SEED_SETTING_KEY']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-default-site-seed-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const seedModule = await import('./defaultSiteSeedService.js'); + + db = dbModule.db; + schema = dbModule.schema; + ensureDefaultSitesSeeded = seedModule.ensureDefaultSitesSeeded; + DEFAULT_SITE_SEED_SETTING_KEY = seedModule.DEFAULT_SITE_SEED_SETTING_KEY; + }); + + beforeEach(async () => { + await db.delete(schema.settings).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(() => { + delete process.env.DATA_DIR; + }); + + it('seeds default official sites once when no site exists yet', async () => { + await ensureDefaultSitesSeeded(); + + const sites = await db.select().from(schema.sites).all(); + const sorted = [...sites].sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0)); + expect(sorted.map((site) => ({ + name: site.name, + url: site.url, + platform: site.platform, + status: site.status, + useSystemProxy: site.useSystemProxy, + isPinned: site.isPinned, + globalWeight: site.globalWeight, + sortOrder: site.sortOrder, + }))).toEqual([ + { + name: 'OpenAI 官方', + url: 'https://api.openai.com', + platform: 'openai', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 0, + }, + { + name: 'Claude 官方', + url: 'https://api.anthropic.com', + platform: 'claude', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 1, + }, + { + name: 'Gemini 官方', + url: 'https://generativelanguage.googleapis.com', + platform: 'gemini', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 2, + }, + { + name: 'CLIProxyAPI', + url: 'http://127.0.0.1:8317', + platform: 'cliproxyapi', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 3, + }, + ]); + + const setting = await db.select().from(schema.settings).where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY)).get(); + expect(JSON.parse(setting?.value || 'false')).toBe(true); + }); + + it('marks first-run evaluation without seeding when sites already exist', async () => { + await db.insert(schema.sites).values({ + name: 'Existing Site', + url: 'https://existing.example.com', + platform: 'new-api', + }).run(); + + await ensureDefaultSitesSeeded(); + + const sites = await db.select().from(schema.sites).all(); + expect(sites).toHaveLength(1); + expect(sites[0]?.name).toBe('Existing Site'); + + const setting = await db.select().from(schema.settings).where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY)).get(); + expect(JSON.parse(setting?.value || 'false')).toBe(true); + }); + + it('does not seed again after the evaluation marker exists', async () => { + await db.insert(schema.settings).values({ + key: DEFAULT_SITE_SEED_SETTING_KEY, + value: JSON.stringify(true), + }).run(); + + await ensureDefaultSitesSeeded(); + + const sites = await db.select().from(schema.sites).all(); + expect(sites).toHaveLength(0); + }); +}); diff --git a/src/server/services/defaultSiteSeedService.ts b/src/server/services/defaultSiteSeedService.ts new file mode 100644 index 0000000..d7329fb --- /dev/null +++ b/src/server/services/defaultSiteSeedService.ts @@ -0,0 +1,105 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; + +export const DEFAULT_SITE_SEED_SETTING_KEY = 'default_site_seed_v1'; + +const DEFAULT_SITE_ROWS: Array = [ + { + name: 'OpenAI 官方', + url: 'https://api.openai.com', + platform: 'openai', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 0, + }, + { + name: 'Claude 官方', + url: 'https://api.anthropic.com', + platform: 'claude', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 1, + }, + { + name: 'Gemini 官方', + url: 'https://generativelanguage.googleapis.com', + platform: 'gemini', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 2, + }, + { + name: 'CLIProxyAPI', + url: 'http://127.0.0.1:8317', + platform: 'cliproxyapi', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 3, + }, +]; + +type SeedSummary = { + seeded: number; + alreadyMarked: boolean; + hadExistingSites: boolean; +}; + +async function writeSeedMarker(tx: typeof db) { + await tx.insert(schema.settings) + .values({ + key: DEFAULT_SITE_SEED_SETTING_KEY, + value: JSON.stringify(true), + }) + .onConflictDoUpdate({ + target: schema.settings.key, + set: { value: JSON.stringify(true) }, + }) + .run(); +} + +export async function ensureDefaultSitesSeeded(): Promise { + return db.transaction(async (tx) => { + const marker = await tx.select({ key: schema.settings.key }) + .from(schema.settings) + .where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY)) + .get(); + + if (marker) { + return { + seeded: 0, + alreadyMarked: true, + hadExistingSites: false, + }; + } + + const existingSite = await tx.select({ id: schema.sites.id }) + .from(schema.sites) + .limit(1) + .get(); + + if (existingSite) { + await writeSeedMarker(tx); + return { + seeded: 0, + alreadyMarked: false, + hadExistingSites: true, + }; + } + + await tx.insert(schema.sites).values(DEFAULT_SITE_ROWS).run(); + await writeSeedMarker(tx); + return { + seeded: DEFAULT_SITE_ROWS.length, + alreadyMarked: false, + hadExistingSites: false, + }; + }); +} diff --git a/src/server/services/factoryResetService.test.ts b/src/server/services/factoryResetService.test.ts new file mode 100644 index 0000000..afda64a --- /dev/null +++ b/src/server/services/factoryResetService.test.ts @@ -0,0 +1,90 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +type DbModule = typeof import('../db/index.js'); +type ConfigModule = typeof import('../config.js'); +type ServiceModule = typeof import('./factoryResetService.js'); + +describe('factoryResetService', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let config: ConfigModule['config']; + let performFactoryReset: ServiceModule['performFactoryReset']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-factory-reset-service-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const configModule = await import('../config.js'); + const serviceModule = await import('./factoryResetService.js'); + + db = dbModule.db; + schema = dbModule.schema; + config = configModule.config; + performFactoryReset = serviceModule.performFactoryReset; + }); + + beforeEach(async () => { + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.proxyVideoTasks).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.sites).run(); + await db.delete(schema.downstreamApiKeys).run(); + await db.delete(schema.events).run(); + await db.delete(schema.settings).run(); + + config.authToken = 'external-reset-token'; + config.dbType = 'postgres'; + config.dbUrl = 'postgres://user:pass@127.0.0.1:5432/metapi'; + config.dbSsl = true; + }); + + afterAll(() => { + delete process.env.DATA_DIR; + }); + + it('clears current active data before switching runtime back to sqlite', async () => { + await db.insert(schema.sites).values({ + name: 'External Runtime Site', + url: 'https://external.example.com', + platform: 'new-api', + }).run(); + await db.insert(schema.settings).values([ + { key: 'auth_token', value: JSON.stringify('external-reset-token') }, + { key: 'db_type', value: JSON.stringify('postgres') }, + { key: 'db_url', value: JSON.stringify('postgres://user:pass@127.0.0.1:5432/metapi') }, + { key: 'db_ssl', value: JSON.stringify(true) }, + ]).run(); + + const switchRuntimeDatabase = vi.fn(async () => undefined); + const runSqliteMigrations = vi.fn(() => undefined); + const ensureDefaultSitesSeeded = vi.fn(async () => ({ + seeded: 0, + alreadyMarked: false, + hadExistingSites: false, + })); + + await performFactoryReset({ + switchRuntimeDatabase, + runSqliteMigrations, + ensureDefaultSitesSeeded, + }); + + expect(switchRuntimeDatabase).toHaveBeenCalledWith('sqlite', '', false); + expect(runSqliteMigrations).toHaveBeenCalledTimes(1); + expect(ensureDefaultSitesSeeded).toHaveBeenCalledTimes(1); + expect(await db.select().from(schema.sites).all()).toHaveLength(0); + expect(await db.select().from(schema.settings).all()).toHaveLength(0); + }); +}); diff --git a/src/server/services/factoryResetService.ts b/src/server/services/factoryResetService.ts new file mode 100644 index 0000000..f97f3b4 --- /dev/null +++ b/src/server/services/factoryResetService.ts @@ -0,0 +1,61 @@ +import { buildConfig, config } from '../config.js'; +import { db, schema, switchRuntimeDatabase } from '../db/index.js'; +import { updateBalanceRefreshCron, updateCheckinCron } from './checkinScheduler.js'; +import { ensureDefaultSitesSeeded } from './defaultSiteSeedService.js'; +import { invalidateSiteProxyCache } from './siteProxy.js'; + +export const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token'; + +type FactoryResetDependencies = { + switchRuntimeDatabase?: typeof switchRuntimeDatabase; + runSqliteMigrations?: () => Promise | void; + ensureDefaultSitesSeeded?: typeof ensureDefaultSitesSeeded; +}; + +async function clearAllBusinessData() { + await db.transaction(async (tx) => { + await tx.delete(schema.routeChannels).run(); + await tx.delete(schema.tokenModelAvailability).run(); + await tx.delete(schema.modelAvailability).run(); + await tx.delete(schema.proxyLogs).run(); + await tx.delete(schema.proxyVideoTasks).run(); + await tx.delete(schema.checkinLogs).run(); + await tx.delete(schema.accountTokens).run(); + await tx.delete(schema.accounts).run(); + await tx.delete(schema.tokenRoutes).run(); + await tx.delete(schema.sites).run(); + await tx.delete(schema.downstreamApiKeys).run(); + await tx.delete(schema.events).run(); + await tx.delete(schema.settings).run(); + }); +} + +function resetRuntimeConfigToInitialState() { + const baseline = buildConfig(process.env); + Object.assign(config, baseline); + config.authToken = FACTORY_RESET_ADMIN_TOKEN; + config.dbType = 'sqlite'; + config.dbUrl = ''; + config.dbSsl = false; + updateCheckinCron(config.checkinCron); + updateBalanceRefreshCron(config.balanceRefreshCron); + invalidateSiteProxyCache(); +} + +async function runDefaultSqliteMigrations() { + const migrateModule = await import('../db/migrate.js'); + migrateModule.runSqliteMigrations(); +} + +export async function performFactoryReset(deps: FactoryResetDependencies = {}): Promise { + const switchRuntimeDatabaseImpl = deps.switchRuntimeDatabase ?? switchRuntimeDatabase; + const runSqliteMigrationsImpl = deps.runSqliteMigrations ?? runDefaultSqliteMigrations; + const ensureDefaultSitesSeededImpl = deps.ensureDefaultSitesSeeded ?? ensureDefaultSitesSeeded; + + await clearAllBusinessData(); + resetRuntimeConfigToInitialState(); + await switchRuntimeDatabaseImpl('sqlite', '', false); + await runSqliteMigrationsImpl(); + await clearAllBusinessData(); + await ensureDefaultSitesSeededImpl(); +} diff --git a/src/server/services/modelPricingService.ts b/src/server/services/modelPricingService.ts index 3bf010e..eb4f050 100644 --- a/src/server/services/modelPricingService.ts +++ b/src/server/services/modelPricingService.ts @@ -57,7 +57,7 @@ interface RoutingReferenceCostCacheEntry { costs: Map; } -interface EstimateProxyCostInput { +export interface EstimateProxyCostInput { site: { id: number; url: string; @@ -497,6 +497,20 @@ async function getPricingDataCached(input: EstimateProxyCostInput): Promise { + const key = getCacheKey(input); + const now = Date.now(); + const data = await fetchPricingData(input); + const ttlMs = data ? PRICE_CACHE_TTL_MS : PRICE_CACHE_FAILURE_TTL_MS; + pricingCache.set(key, { + fetchedAt: now, + ttlMs, + data, + }); + syncRoutingReferenceCostCache(key, now, ttlMs, data); + return data; +} + export function getCachedModelRoutingReferenceCost(input: { siteId: number; accountId: number; @@ -708,10 +722,7 @@ export function calculateModelUsageCost( return calculateModelUsageBreakdown(model, usage, groupRatio)?.breakdown.totalCost ?? 0; } -export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): Promise { - const pricingData = await getPricingDataCached(input); - if (!pricingData) return null; - +function buildModelPricingCatalogFromData(pricingData: PricingData): ModelPricingCatalog { const groups = Array.from(new Set([DEFAULT_GROUP, ...Object.keys(pricingData.groupRatio)])); const defaultMultiplier = pricingData.groupRatio[DEFAULT_GROUP] || 1; @@ -763,6 +774,18 @@ export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): P }; } +export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): Promise { + const pricingData = await getPricingDataCached(input); + if (!pricingData) return null; + return buildModelPricingCatalogFromData(pricingData); +} + +export async function refreshModelPricingCatalog(input: EstimateProxyCostInput): Promise { + const pricingData = await refreshPricingDataCache(input); + if (!pricingData) return null; + return buildModelPricingCatalogFromData(pricingData); +} + export function fallbackTokenCost(totalTokens: number, platform: string): number { const divisor = platform === 'veloera' ? 1_000_000 : 500_000; return roundCost(toPositiveInt(totalTokens) / divisor); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 556d741..399108a 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -5,6 +5,7 @@ import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accoun import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; import { invalidateTokenRouterCache } from './tokenRouter.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; +import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; @@ -343,10 +344,10 @@ export async function rebuildTokenRoutesFromAvailability() { } const latestModelNames = new Set(Array.from(modelTokens.keys())); - for (const route of routes) { - const modelPattern = (route.modelPattern || '').trim(); - if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) { - continue; + 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; @@ -357,10 +358,14 @@ export async function rebuildTokenRoutesFromAvailability() { const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes; if (deleted > 0) { removedRoutes += deleted; - } - } - - invalidateTokenRouterCache(); + } + } + + if (createdRoutes > 0 || createdChannels > 0 || removedChannels > 0 || removedRoutes > 0) { + await clearAllRouteDecisionSnapshots(); + } + + invalidateTokenRouterCache(); return { models: modelTokens.size, diff --git a/src/server/services/proxyVideoTaskStore.ts b/src/server/services/proxyVideoTaskStore.ts new file mode 100644 index 0000000..dc34b3c --- /dev/null +++ b/src/server/services/proxyVideoTaskStore.ts @@ -0,0 +1,127 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { formatUtcSqlDateTime } from './localTimeService.js'; + +export type SaveProxyVideoTaskInput = { + publicId?: string; + upstreamVideoId: string; + siteUrl: string; + tokenValue: string; + requestedModel: string; + actualModel: string; + channelId: number | null; + accountId: number | null; + statusSnapshot?: unknown; + upstreamResponseMeta?: unknown; + lastUpstreamStatus?: number | null; + lastPolledAt?: string | null; +}; + +export type ProxyVideoTaskRecord = { + publicId: string; + upstreamVideoId: string; + siteUrl: string; + tokenValue: string; + requestedModel: string | null; + actualModel: string | null; + channelId: number | null; + accountId: number | null; + statusSnapshot: unknown | null; + upstreamResponseMeta: unknown | null; + lastUpstreamStatus: number | null; + lastPolledAt: string | null; +}; + +function buildPublicVideoId(): string { + const timePart = Date.now().toString(36); + const randomPart = Math.random().toString(36).slice(2, 10); + return `vid_${timePart}_${randomPart}`; +} + +export async function saveProxyVideoTask(input: SaveProxyVideoTaskInput): Promise<{ + publicId: string; + upstreamVideoId: string; +}> { + const publicId = input.publicId?.trim() || buildPublicVideoId(); + const now = formatUtcSqlDateTime(new Date()); + + await db.insert(schema.proxyVideoTasks).values({ + publicId, + upstreamVideoId: input.upstreamVideoId, + siteUrl: input.siteUrl, + tokenValue: input.tokenValue, + requestedModel: input.requestedModel, + actualModel: input.actualModel, + channelId: input.channelId, + accountId: input.accountId, + statusSnapshot: input.statusSnapshot === undefined ? null : JSON.stringify(input.statusSnapshot), + upstreamResponseMeta: input.upstreamResponseMeta === undefined ? null : JSON.stringify(input.upstreamResponseMeta), + lastUpstreamStatus: input.lastUpstreamStatus ?? null, + lastPolledAt: input.lastPolledAt ?? (input.lastUpstreamStatus == null ? null : now), + createdAt: now, + updatedAt: now, + }).run(); + + return { + publicId, + upstreamVideoId: input.upstreamVideoId, + }; +} + +export async function getProxyVideoTaskByPublicId(publicId: string): Promise { + const row = await db.select().from(schema.proxyVideoTasks) + .where(eq(schema.proxyVideoTasks.publicId, publicId)) + .get(); + if (!row) return null; + return { + publicId: row.publicId, + upstreamVideoId: row.upstreamVideoId, + siteUrl: row.siteUrl, + tokenValue: row.tokenValue, + requestedModel: row.requestedModel, + actualModel: row.actualModel, + channelId: row.channelId, + accountId: row.accountId, + statusSnapshot: parseJsonColumn(row.statusSnapshot), + upstreamResponseMeta: parseJsonColumn(row.upstreamResponseMeta), + lastUpstreamStatus: row.lastUpstreamStatus, + lastPolledAt: row.lastPolledAt, + }; +} + +export async function deleteProxyVideoTaskByPublicId(publicId: string): Promise { + await db.delete(schema.proxyVideoTasks) + .where(eq(schema.proxyVideoTasks.publicId, publicId)) + .run(); +} + +export async function refreshProxyVideoTaskSnapshot( + publicId: string, + input: { + statusSnapshot?: unknown; + upstreamResponseMeta?: unknown; + lastUpstreamStatus?: number | null; + lastPolledAt?: string | null; + }, +): Promise { + const now = formatUtcSqlDateTime(new Date()); + await db.update(schema.proxyVideoTasks) + .set({ + statusSnapshot: input.statusSnapshot === undefined ? null : JSON.stringify(input.statusSnapshot), + upstreamResponseMeta: input.upstreamResponseMeta === undefined ? null : JSON.stringify(input.upstreamResponseMeta), + lastUpstreamStatus: input.lastUpstreamStatus ?? null, + lastPolledAt: input.lastPolledAt ?? now, + updatedAt: now, + }) + .where(eq(schema.proxyVideoTasks.publicId, publicId)) + .run(); +} + +function parseJsonColumn(value: string | null | undefined): unknown | null { + if (typeof value !== 'string' || value.trim().length === 0) return null; + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/src/server/services/routeDecisionSnapshotStore.ts b/src/server/services/routeDecisionSnapshotStore.ts new file mode 100644 index 0000000..b8e0b1a --- /dev/null +++ b/src/server/services/routeDecisionSnapshotStore.ts @@ -0,0 +1,64 @@ +import { eq, inArray } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { formatUtcSqlDateTime } from './localTimeService.js'; + +function serializeSnapshot(snapshot: unknown): string | null { + if (snapshot == null) return null; + return JSON.stringify(snapshot); +} + +export function parseRouteDecisionSnapshot(value: string | null | undefined): unknown | null { + if (typeof value !== 'string' || value.trim().length === 0) return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +export async function saveRouteDecisionSnapshot(routeId: number, snapshot: unknown): Promise { + const refreshedAt = formatUtcSqlDateTime(new Date()); + await db.update(schema.tokenRoutes) + .set({ + decisionSnapshot: serializeSnapshot(snapshot), + decisionRefreshedAt: refreshedAt, + }) + .where(eq(schema.tokenRoutes.id, routeId)) + .run(); +} + +export async function saveRouteDecisionSnapshots(entries: Array<{ routeId: number; snapshot: unknown }>): Promise { + for (const entry of entries) { + await saveRouteDecisionSnapshot(entry.routeId, entry.snapshot); + } +} + +export async function clearRouteDecisionSnapshot(routeId: number): Promise { + await clearRouteDecisionSnapshots([routeId]); +} + +export async function clearRouteDecisionSnapshots(routeIds: number[]): Promise { + const normalizedRouteIds = Array.from(new Set( + routeIds + .map((routeId) => Math.trunc(routeId)) + .filter((routeId) => routeId > 0), + )); + if (normalizedRouteIds.length === 0) return; + + await db.update(schema.tokenRoutes) + .set({ + decisionSnapshot: null, + decisionRefreshedAt: null, + }) + .where(inArray(schema.tokenRoutes.id, normalizedRouteIds)) + .run(); +} + +export async function clearAllRouteDecisionSnapshots(): Promise { + await db.update(schema.tokenRoutes) + .set({ + decisionSnapshot: null, + decisionRefreshedAt: null, + }) + .run(); +} diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 52bc39a..efb8ad8 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -2,7 +2,7 @@ import { minimatch } from 'minimatch'; import { db, schema } from '../db/index.js'; import { config } from '../config.js'; -import { getCachedModelRoutingReferenceCost } from './modelPricingService.js'; +import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js'; import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; interface RouteMatch { @@ -195,6 +195,12 @@ type ExplainSelectionOptions = { downstreamPolicy?: DownstreamRoutingPolicy; }; +type PricingReferenceRefreshOptions = { + useChannelSourceModelForCost?: boolean; + downstreamPolicy?: DownstreamRoutingPolicy; + refreshedKeys?: Set; +}; + type CandidateEligibilityOptions = { requestedModel: string; bypassSourceModelCheck?: boolean; @@ -546,15 +552,47 @@ export class TokenRouter { const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`; return this.explainSelectionFromMatch(match, fallbackRequestedModel, { bypassSourceModelCheck: true, - useChannelSourceModelForCost: true, - downstreamPolicy, - }); - } - - private explainSelectionFromMatch( - match: RouteMatch | null, - requestedModel: string, - options: ExplainSelectionOptions = {}, + useChannelSourceModelForCost: true, + downstreamPolicy, + }); + } + + async refreshPricingReferenceCosts( + requestedModel: string, + options: PricingReferenceRefreshOptions = {}, + ): Promise { + const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; + const match = await this.findRoute(requestedModel, downstreamPolicy); + await this.refreshPricingReferenceCostsForMatch(match, requestedModel, options); + } + + async refreshPricingReferenceCostsForRoute( + routeId: number, + requestedModel: string, + options: PricingReferenceRefreshOptions = {}, + ): Promise { + const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; + const match = await this.findRouteById(routeId, downstreamPolicy); + await this.refreshPricingReferenceCostsForMatch(match, requestedModel, options); + } + + async refreshRouteWidePricingReferenceCosts( + routeId: number, + options: Omit = {}, + ): Promise { + const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; + const match = await this.findRouteById(routeId, downstreamPolicy); + const requestedModel = match?.route.modelPattern || `route:${routeId}`; + await this.refreshPricingReferenceCostsForMatch(match, requestedModel, { + ...options, + useChannelSourceModelForCost: true, + }); + } + + private explainSelectionFromMatch( + match: RouteMatch | null, + requestedModel: string, + options: ExplainSelectionOptions = {}, ): RouteDecisionExplanation { const excludeChannelIds = options.excludeChannelIds ?? []; const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; @@ -706,23 +744,62 @@ export class TokenRouter { summary.push(`实际转发模型:${actualModel}`); } - return { - requestedModel, - actualModel, - matched: true, - routeId: match.route.id, + return { + requestedModel, + actualModel, + matched: true, + routeId: match.route.id, modelPattern: match.route.modelPattern, selectedChannelId: selected.channel.id, selectedAccountId: selected.account.id, selectedLabel, - summary, - candidates, - }; - } - - /** - * Record success for a channel. - */ + summary, + candidates, + }; + } + + private async refreshPricingReferenceCostsForMatch( + match: RouteMatch | null, + requestedModel: string, + options: PricingReferenceRefreshOptions = {}, + ): Promise { + if (!match) return; + + const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); + const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName; + const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); + const refreshedKeys = options.refreshedKeys ?? new Set(); + + await Promise.allSettled(match.channels.map(async (candidate) => { + const refreshKey = `${candidate.site.id}:${candidate.account.id}`; + if (refreshedKeys.has(refreshKey)) return; + refreshedKeys.add(refreshKey); + + const modelName = useChannelSourceModelForCost + ? (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) + : mappedModel; + if (!modelName) return; + + await refreshModelPricingCatalog({ + site: { + id: candidate.site.id, + url: candidate.site.url, + platform: candidate.site.platform, + apiKey: candidate.site.apiKey, + }, + account: { + id: candidate.account.id, + accessToken: candidate.account.accessToken, + apiToken: candidate.account.apiToken, + }, + modelName, + }); + })); + } + + /** + * Record success for a channel. + */ async recordSuccess(channelId: number, latencyMs: number, cost: number) { const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); if (!ch) return; diff --git a/src/server/transformers/anthropic/messages/aggregator.ts b/src/server/transformers/anthropic/messages/aggregator.ts new file mode 100644 index 0000000..9f50b5a --- /dev/null +++ b/src/server/transformers/anthropic/messages/aggregator.ts @@ -0,0 +1,179 @@ +import { type NormalizedStreamEvent } from '../../shared/normalized.js'; + +export type AnthropicStreamExtension = { + signatureDelta?: string; + redactedThinkingData?: string; + startBlock?: { + kind: 'thinking' | 'text' | 'tool_use' | 'redacted_thinking'; + index?: number; + }; + stopBlockIndex?: number | null; +}; + +export type AnthropicExtendedStreamEvent = NormalizedStreamEvent & { + anthropic?: AnthropicStreamExtension; +}; + +export type AnthropicBlockLifecycleEntry = { + kind: 'thinking' | 'redacted_thinking'; + phase: 'start' | 'stop'; + index?: number; +}; + +export type AnthropicMessagesAggregateState = { + text: string[]; + reasoning: string[]; + redactedReasoning: string[]; + signatures: string[]; + toolCalls: Record; + finishReason: string | null; + pendingSignature: string | null; + thinkingBlockIndex: number | null; + redactedBlockIndexes: number[]; + blockLifecycle: AnthropicBlockLifecycleEntry[]; +}; + +export function createAnthropicMessagesAggregateState(): AnthropicMessagesAggregateState { + return { + text: [], + reasoning: [], + redactedReasoning: [], + signatures: [], + toolCalls: {}, + finishReason: null, + pendingSignature: null, + thinkingBlockIndex: null, + redactedBlockIndexes: [], + blockLifecycle: [], + }; +} + +function ensureToolState( + state: AnthropicMessagesAggregateState, + index: number, +): { id: string; name: string; arguments: string } { + if (!state.toolCalls[index]) { + state.toolCalls[index] = { + id: `toolu_${index}`, + name: '', + arguments: '', + }; + } + return state.toolCalls[index]; +} + +function flushPendingSignature(state: AnthropicMessagesAggregateState): void { + if (!state.pendingSignature) return; + state.signatures.push(state.pendingSignature); + state.pendingSignature = null; +} + +function recordBlockStart( + state: AnthropicMessagesAggregateState, + kind: AnthropicBlockLifecycleEntry['kind'], + index?: number, +): void { + state.blockLifecycle.push({ kind, phase: 'start', index }); + if (kind === 'thinking') { + state.thinkingBlockIndex = typeof index === 'number' ? index : state.thinkingBlockIndex; + return; + } + if (kind === 'redacted_thinking' && typeof index === 'number') { + state.redactedBlockIndexes.push(index); + } +} + +function recordBlockStop(state: AnthropicMessagesAggregateState, index: number): void { + if (state.thinkingBlockIndex === index) { + state.blockLifecycle.push({ kind: 'thinking', phase: 'stop', index }); + state.thinkingBlockIndex = null; + flushPendingSignature(state); + return; + } + + const redactedIndex = state.redactedBlockIndexes.indexOf(index); + if (redactedIndex >= 0) { + state.blockLifecycle.push({ kind: 'redacted_thinking', phase: 'stop', index }); + state.redactedBlockIndexes.splice(redactedIndex, 1); + } +} + +function finalizeOpenBlocks(state: AnthropicMessagesAggregateState): void { + if (state.thinkingBlockIndex !== null) { + const thinkingIndex = state.thinkingBlockIndex; + recordBlockStop(state, thinkingIndex); + } + + if (state.pendingSignature) { + flushPendingSignature(state); + } + + const openRedactedIndexes = [...state.redactedBlockIndexes]; + for (const redactedIndex of openRedactedIndexes) { + recordBlockStop(state, redactedIndex); + } +} + +export function applyAnthropicMessagesAggregateEvent( + state: AnthropicMessagesAggregateState, + event: AnthropicExtendedStreamEvent, +): AnthropicMessagesAggregateState { + if (event.anthropic?.startBlock?.kind === 'thinking') { + recordBlockStart(state, 'thinking', event.anthropic.startBlock.index); + } + if (event.anthropic?.startBlock?.kind === 'redacted_thinking') { + if (state.thinkingBlockIndex !== null) { + recordBlockStop(state, state.thinkingBlockIndex); + } + recordBlockStart(state, 'redacted_thinking', event.anthropic.startBlock.index); + } + + if (event.reasoningDelta) { + state.reasoning.push(event.reasoningDelta); + } + + if (event.contentDelta) { + state.text.push(event.contentDelta); + } + + if (Array.isArray(event.toolCallDeltas)) { + for (const toolDelta of event.toolCallDeltas) { + const toolState = ensureToolState(state, toolDelta.index); + if (toolDelta.id) toolState.id = toolDelta.id; + if (toolDelta.name) toolState.name = toolDelta.name; + if (toolDelta.argumentsDelta) { + toolState.arguments += toolDelta.argumentsDelta; + } + } + } + + if (event.anthropic?.signatureDelta) { + if (state.thinkingBlockIndex !== null) { + state.signatures.push(event.anthropic.signatureDelta); + } else { + state.pendingSignature = event.anthropic.signatureDelta; + } + } + + if (event.anthropic?.redactedThinkingData) { + state.redactedReasoning.push(event.anthropic.redactedThinkingData); + } + + if ( + event.anthropic?.stopBlockIndex !== undefined + && event.anthropic.stopBlockIndex !== null + ) { + recordBlockStop(state, event.anthropic.stopBlockIndex); + } + + if (event.finishReason) { + finalizeOpenBlocks(state); + state.finishReason = event.finishReason; + } + + if (event.done) { + finalizeOpenBlocks(state); + } + + return state; +} diff --git a/src/server/transformers/anthropic/messages/conversion.test.ts b/src/server/transformers/anthropic/messages/conversion.test.ts new file mode 100644 index 0000000..5730780 --- /dev/null +++ b/src/server/transformers/anthropic/messages/conversion.test.ts @@ -0,0 +1,666 @@ +import { describe, expect, it } from 'vitest'; + +import { + convertOpenAiBodyToAnthropicMessagesBody, + convertOpenAiToolChoiceToAnthropic, + sanitizeAnthropicMessagesBody, +} from './conversion.js'; +import { anthropicMessagesInbound } from './inbound.js'; +import { anthropicMessagesTransformer } from './index.js'; +import { extractAnthropicUsage, extractAnthropicUsageMetadata } from './usage.js'; +import { applyAnthropicMessagesAggregateEvent, createAnthropicMessagesAggregateState } from './aggregator.js'; + +describe('sanitizeAnthropicMessagesBody', () => { + it('drops top_p when temperature is present and normalizes adaptive thinking', () => { + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus', + temperature: 0.6, + top_p: 0.9, + thinking: { + type: 'adaptive', + budget_tokens: 512, + }, + }); + + expect(result.top_p).toBeUndefined(); + expect(result.thinking).toEqual({ + type: 'enabled', + budget_tokens: 512, + }); + }); + + it('drops non-ephemeral cache_control markers while preserving valid tool block markers', () => { + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus-4-6', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'internal', + cache_control: { type: 'persistent' }, + }, + { + type: 'tool_use', + id: 'tool_1', + name: 'lookup', + input: { city: 'paris' }, + cache_control: { type: 'ephemeral' }, + }, + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: 'done', + cache_control: { type: 'persistent' }, + }, + ], + }, + ], + }); + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'internal', + }, + { + type: 'tool_use', + id: 'tool_1', + name: 'lookup', + input: { city: 'paris' }, + }, + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: 'done', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); +}); + +describe('convertOpenAiBodyToAnthropicMessagesBody', () => { + it('preserves image blocks, tool_use blocks, and tool_result chains', () => { + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + messages: [ + { + role: 'system', + content: 'system prompt', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'look at this' }, + { type: 'image_url', image_url: 'https://example.com/cat.png' }, + ], + }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup', + arguments: '{"topic":"cat"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: '{"ok":true}', + }, + { + role: 'user', + content: 'thanks', + }, + ], + }, + 'claude-opus-4-6', + false, + ); + + expect(body.system).toBe('system prompt'); + expect(body.messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'look at this' }, + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/cat.png', + }, + }, + ], + }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: { topic: 'cat' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_1', + content: '{"ok":true}', + }, + { + type: 'text', + text: 'thanks', + }, + ], + }, + ]); + }); + + it('strips cache_control from thinking blocks and empty text blocks', () => { + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'internal', + cache_control: { type: 'ephemeral' }, + }, + { + type: 'text', + text: ' ', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + }, + 'claude-opus-4-6', + false, + ); + + expect(body.messages).toEqual([ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'internal', + }, + ], + }, + ]); + }); + + it('preserves cache_control on tool blocks while stripping unsupported thinking/text markers', () => { + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus-4-6', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'internal', + cache_control: { type: 'ephemeral' }, + }, + { + type: 'tool_use', + id: 'tool_1', + name: 'lookup', + input: { city: 'paris' }, + cache_control: { type: 'ephemeral' }, + }, + { + type: 'text', + text: '', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + }); + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'internal', + }, + { + type: 'tool_use', + id: 'tool_1', + name: 'lookup', + input: { city: 'paris' }, + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + + it('rebuilds cache_control anchors on structural blocks and the last cacheable message block', () => { + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus-4-6', + tools: [ + { name: 'lookup', input_schema: { type: 'object' }, cache_control: { type: 'ephemeral' } }, + { name: 'search', input_schema: { type: 'object' } }, + ], + system: [ + { type: 'text', text: 'system 1' }, + { type: 'text', text: 'system 2', cache_control: { type: 'ephemeral' } }, + ], + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'prefix' }, + { type: 'thinking', thinking: 'internal', cache_control: { type: 'ephemeral' } }, + { type: 'tool_use', id: 'tool_1', name: 'lookup', input: { city: 'paris' } }, + ], + }, + ], + }); + + expect(result.tools).toEqual([ + { name: 'lookup', input_schema: { type: 'object' } }, + { name: 'search', input_schema: { type: 'object' }, cache_control: { type: 'ephemeral' } }, + ]); + expect(result.system).toEqual([ + { type: 'text', text: 'system 1' }, + { type: 'text', text: 'system 2', cache_control: { type: 'ephemeral' } }, + ]); + expect(result.messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'prefix' }, + { type: 'thinking', thinking: 'internal' }, + { type: 'tool_use', id: 'tool_1', name: 'lookup', input: { city: 'paris' }, cache_control: { type: 'ephemeral' } }, + ], + }, + ]); + }); + + it('adds a second message cache anchor around the 20-block window for long prompts', () => { + const longContent = Array.from({ length: 25 }, (_, index) => ({ + type: 'text', + text: `block-${index + 1}`, + })); + + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus-4-6', + messages: [ + { + role: 'user', + content: longContent, + }, + ], + }); + + const content = (result.messages as Array)[0].content as Array; + const anchoredIndexes = content + .map((item, index) => item?.cache_control ? index : -1) + .filter((index) => index >= 0); + + expect(anchoredIndexes).toEqual([4, 24]); + }); + + it('maps reasoning effort to anthropic output_config.effort with adaptive thinking', () => { + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + reasoning_effort: 'high', + messages: [ + { role: 'user', content: 'hello' }, + ], + }, + 'claude-opus-4-6', + true, + ); + + expect(body.thinking).toEqual({ type: 'adaptive' }); + expect(body.output_config).toEqual({ effort: 'high' }); + }); +}); + +describe('convertOpenAiToolChoiceToAnthropic', () => { + it('maps required to any and function choice to tool', () => { + expect(convertOpenAiToolChoiceToAnthropic('required')).toEqual({ type: 'any' }); + expect(convertOpenAiToolChoiceToAnthropic({ + type: 'function', + function: { name: 'lookup' }, + })).toEqual({ + type: 'tool', + name: 'lookup', + }); + }); + + it('normalizes tool-style objects and strips unsupported name on non-tool variants', () => { + expect(anthropicMessagesInbound.parse({ + model: 'claude-opus-4-6', + max_tokens: 512, + messages: [{ role: 'user', content: 'hello' }], + tool_choice: { type: 'tool', tool: { name: 'lookup' } }, + }).value?.claudeOriginalBody?.tool_choice).toEqual({ + type: 'tool', + name: 'lookup', + }); + + expect(anthropicMessagesInbound.parse({ + model: 'claude-opus-4-6', + max_tokens: 512, + messages: [{ role: 'user', content: 'hello' }], + tool_choice: { type: 'none', name: 'should-disappear' }, + }).value?.claudeOriginalBody?.tool_choice).toEqual({ + type: 'none', + }); + }); +}); + +describe('anthropicMessagesInbound', () => { + it('rejects invalid tool_choice.type and invalid output_config.effort', () => { + const invalidToolChoice = anthropicMessagesInbound.parse({ + model: 'claude-opus-4-6', + max_tokens: 512, + messages: [{ role: 'user', content: 'hello' }], + tool_choice: { type: 'invalid' }, + }); + expect(invalidToolChoice.error?.statusCode).toBe(400); + + const invalidEffort = anthropicMessagesInbound.parse({ + model: 'claude-opus-4-6', + max_tokens: 512, + messages: [{ role: 'user', content: 'hello' }], + thinking: { type: 'adaptive' }, + output_config: { effort: 'turbo' }, + }); + expect(invalidEffort.error?.statusCode).toBe(400); + }); + + it('rejects tool choice without a tool name', () => { + const result = anthropicMessagesInbound.parse({ + model: 'claude-opus-4-6', + max_tokens: 512, + messages: [{ role: 'user', content: 'hello' }], + tool_choice: { type: 'tool' }, + }); + + expect(result.error?.statusCode).toBe(400); + }); +}); + +describe('anthropicMessagesTransformer.stream', () => { + it('serializes reasoning separately from text for Claude downstream', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const chunks = [ + ...anthropicMessagesTransformer.serializeStreamEvent({ role: 'assistant' }, streamContext, downstreamContext), + ...anthropicMessagesTransformer.serializeStreamEvent({ reasoningDelta: 'internal thought' }, streamContext, downstreamContext), + ...anthropicMessagesTransformer.serializeStreamEvent({ contentDelta: 'final answer' }, streamContext, downstreamContext), + ...anthropicMessagesTransformer.serializeStreamEvent({ finishReason: 'stop', done: true }, streamContext, downstreamContext), + ].join(''); + + expect(chunks).toContain('"type":"thinking_delta"'); + expect(chunks).toContain('"type":"text_delta"'); + expect(chunks).not.toContain('internal thought\\n\\nfinal answer'); + }); + + it('buffers signature_delta until a thinking block is closed', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const signatureEvent = anthropicMessagesTransformer.transformStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'sig-1' }, + }, streamContext, 'claude-opus-4-6'); + const thinkingStart = anthropicMessagesTransformer.transformStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }, streamContext, 'claude-opus-4-6'); + const thinkingDelta = anthropicMessagesTransformer.transformStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'internal' }, + }, streamContext, 'claude-opus-4-6'); + const textDelta = anthropicMessagesTransformer.transformStreamEvent({ + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'final' }, + }, streamContext, 'claude-opus-4-6'); + + const serialized = [ + ...anthropicMessagesTransformer.serializeStreamEvent(signatureEvent, streamContext, downstreamContext), + ...anthropicMessagesTransformer.serializeStreamEvent(thinkingStart, streamContext, downstreamContext), + ...anthropicMessagesTransformer.serializeStreamEvent(thinkingDelta, streamContext, downstreamContext), + ...anthropicMessagesTransformer.serializeStreamEvent(textDelta, streamContext, downstreamContext), + ].join(''); + + expect(serialized).toContain('"type":"signature_delta"'); + expect(serialized.indexOf('"type":"signature_delta"')).toBeGreaterThan(serialized.indexOf('"type":"thinking_delta"')); + expect(serialized.indexOf('"type":"signature_delta"')).toBeLessThan(serialized.indexOf('"type":"text_delta"')); + }); + + it('emits redacted_thinking as its own lifecycle block', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const redacted = anthropicMessagesTransformer.transformStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'redacted_thinking', data: 'ciphertext' }, + }, streamContext, 'claude-opus-4-6'); + + const serialized = anthropicMessagesTransformer.serializeStreamEvent(redacted, streamContext, downstreamContext).join(''); + expect(serialized).toContain('"type":"redacted_thinking"'); + expect(serialized).toContain('content_block_start'); + expect(serialized).toContain('content_block_stop'); + }); +}); + +describe('extractAnthropicUsage', () => { + it('maps cache read and cache creation tokens into normalized usage', () => { + expect(extractAnthropicUsage({ + usage: { + input_tokens: 100, + output_tokens: 30, + cache_read_input_tokens: 12, + cache_creation_input_tokens: 8, + }, + })).toEqual({ + promptTokens: 120, + completionTokens: 30, + totalTokens: 150, + cachedTokens: 12, + reasoningTokens: 0, + audioInputTokens: 0, + audioOutputTokens: 0, + acceptedPredictionTokens: 0, + rejectedPredictionTokens: 0, + }); + }); + + it('exposes cache read/cache creation details for metadata consumers', () => { + expect(extractAnthropicUsageMetadata({ + usage: { + input_tokens: 100, + output_tokens: 30, + cache_read_input_tokens: 12, + cache_creation_input_tokens: 8, + cache_creation: { + ephemeral_5m_input_tokens: 5, + ephemeral_1h_input_tokens: 3, + }, + }, + })).toEqual({ + cacheReadInputTokens: 12, + cacheCreationInputTokens: 8, + ephemeral5mInputTokens: 5, + ephemeral1hInputTokens: 3, + promptTokensIncludingCache: 120, + }); + }); + + it('derives cache creation input tokens from nested cache_creation when the top-level field is missing', () => { + expect(extractAnthropicUsageMetadata({ + usage: { + input_tokens: 100, + output_tokens: 30, + cache_read_input_tokens: 12, + cache_creation: { + ephemeral_5m_input_tokens: 5, + ephemeral_1h_input_tokens: 3, + }, + }, + })).toEqual({ + cacheReadInputTokens: 12, + cacheCreationInputTokens: 8, + ephemeral5mInputTokens: 5, + ephemeral1hInputTokens: 3, + promptTokensIncludingCache: 120, + }); + }); +}); + +describe('applyAnthropicMessagesAggregateEvent', () => { + it('tracks text, reasoning, redacted thinking, and tool call arguments', () => { + const state = createAnthropicMessagesAggregateState(); + + applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'think-1' }); + applyAnthropicMessagesAggregateEvent(state, { contentDelta: 'answer-1' }); + applyAnthropicMessagesAggregateEvent(state, { + toolCallDeltas: [{ index: 0, id: 'call_1', name: 'lookup', argumentsDelta: '{"city":"par' }], + }); + applyAnthropicMessagesAggregateEvent(state, { + toolCallDeltas: [{ index: 0, argumentsDelta: 'is"}' }], + }); + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + redactedThinkingData: 'cipher', + }, + } as any); + + expect(state.reasoning).toEqual(['think-1']); + expect(state.text).toEqual(['answer-1']); + expect(state.redactedReasoning).toEqual(['cipher']); + expect(state.toolCalls[0]).toEqual({ + id: 'call_1', + name: 'lookup', + arguments: '{"city":"paris"}', + }); + }); + + it('buffers signature until the thinking block closes and records lifecycle markers', () => { + const state = createAnthropicMessagesAggregateState(); + + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + signatureDelta: 'sig-buffered', + }, + } as any); + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + startBlock: { + kind: 'thinking', + index: 2, + }, + }, + } as any); + applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'deliberating' }); + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + stopBlockIndex: 2, + }, + } as any); + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + startBlock: { + kind: 'redacted_thinking', + index: 3, + }, + redactedThinkingData: 'cipher-2', + }, + } as any); + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + stopBlockIndex: 3, + }, + } as any); + + expect(state.pendingSignature).toBeNull(); + expect(state.signatures).toEqual(['sig-buffered']); + expect(state.blockLifecycle).toEqual([ + { kind: 'thinking', phase: 'start', index: 2 }, + { kind: 'thinking', phase: 'stop', index: 2 }, + { kind: 'redacted_thinking', phase: 'start', index: 3 }, + { kind: 'redacted_thinking', phase: 'stop', index: 3 }, + ]); + expect(state.redactedReasoning).toEqual(['cipher-2']); + }); + + it('flushes pending signatures and closes open thinking/redacted blocks on finish', () => { + const state = createAnthropicMessagesAggregateState(); + + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + signatureDelta: 'sig-finish', + }, + } as any); + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + startBlock: { + kind: 'thinking', + index: 4, + }, + }, + } as any); + applyAnthropicMessagesAggregateEvent(state, { + anthropic: { + startBlock: { + kind: 'redacted_thinking', + index: 5, + }, + redactedThinkingData: 'cipher-finish', + }, + } as any); + applyAnthropicMessagesAggregateEvent(state, { + finishReason: 'stop', + done: true, + }); + + expect(state.pendingSignature).toBeNull(); + expect(state.signatures).toEqual(['sig-finish']); + expect(state.blockLifecycle).toEqual([ + { kind: 'thinking', phase: 'start', index: 4 }, + { kind: 'thinking', phase: 'stop', index: 4 }, + { kind: 'redacted_thinking', phase: 'start', index: 5 }, + { kind: 'redacted_thinking', phase: 'stop', index: 5 }, + ]); + expect(state.finishReason).toBe('stop'); + }); +}); diff --git a/src/server/transformers/anthropic/messages/conversion.ts b/src/server/transformers/anthropic/messages/conversion.ts new file mode 100644 index 0000000..0c7e310 --- /dev/null +++ b/src/server/transformers/anthropic/messages/conversion.ts @@ -0,0 +1,881 @@ +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +const VALID_ANTHROPIC_TOOL_CHOICE_TYPES = new Set(['auto', 'none', 'any', 'tool']); +const VALID_ANTHROPIC_THINKING_TYPES = new Set(['enabled', 'disabled', 'adaptive']); +const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']); + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function toFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function parseJsonString(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { value: raw }; + } +} + +function toPositiveInteger(value: unknown): number | null { + const numberValue = toFiniteNumber(value); + if (numberValue === null || numberValue <= 0) return null; + return Math.trunc(numberValue); +} + +function sanitizeCacheControl(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const type = asTrimmedString(value.type).toLowerCase(); + if (type !== 'ephemeral') return undefined; + return { type }; +} + +function sanitizeAnthropicOutputConfig( + value: unknown, +): { value?: Record; error?: string } { + if (!isRecord(value)) return {}; + + const next: Record = {}; + if ('effort' in value) { + const effort = asTrimmedString(value.effort).toLowerCase(); + if (!effort) return { value: next }; + if (!VALID_ANTHROPIC_EFFORTS.has(effort)) { + return { error: 'output_config.effort must be one of: low, medium, high, max' }; + } + next.effort = effort; + } + + for (const [key, entry] of Object.entries(value)) { + if (key === 'effort') continue; + next[key] = entry; + } + + return Object.keys(next).length > 0 ? { value: next } : {}; +} + +function sanitizeAnthropicToolChoiceValue( + value: unknown, +): { value?: Record; error?: string } { + if (value === undefined) return {}; + + if (typeof value === 'string') { + const normalized = asTrimmedString(value).toLowerCase(); + if (!normalized) return {}; + if (normalized === 'required') return { value: { type: 'any' } }; + if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(normalized)) { + return { error: 'tool_choice.type must be one of: auto, none, any, tool' }; + } + return { value: { type: normalized } }; + } + + if (!isRecord(value)) { + return { error: 'tool_choice must be an object or string' }; + } + + const type = asTrimmedString(value.type).toLowerCase(); + if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(type)) { + return { error: 'tool_choice.type must be one of: auto, none, any, tool' }; + } + + const next: Record = { ...value, type }; + if (type === 'tool') { + const name = asTrimmedString(value.name ?? (isRecord(value.tool) ? value.tool.name : undefined)); + if (!name) { + return { error: 'tool_choice.name is required when type is tool' }; + } + next.name = name; + delete next.tool; + } else { + delete next.name; + delete next.tool; + } + + return { value: next }; +} + +function sanitizeAnthropicThinkingConfig( + value: unknown, +): { value?: Record; error?: string } { + if (!isRecord(value)) return {}; + + const type = asTrimmedString(value.type).toLowerCase(); + if (!type) return {}; + if (!VALID_ANTHROPIC_THINKING_TYPES.has(type)) { + return { error: 'thinking.type must be one of: enabled, disabled, adaptive' }; + } + + if (type === 'enabled') { + const budgetTokens = toPositiveInteger(value.budget_tokens ?? value.budgetTokens); + if (!budgetTokens) { + return { error: 'budget_tokens is required and must be positive when thinking.type is enabled' }; + } + return { + value: { + type, + budget_tokens: budgetTokens, + }, + }; + } + + if (type === 'adaptive') { + const budgetTokens = toPositiveInteger(value.budget_tokens ?? value.budgetTokens); + if (budgetTokens) { + return { + value: { + type: 'enabled', + budget_tokens: budgetTokens, + }, + }; + } + } + + return { value: { type } }; +} + +function normalizeOpenAiToolArguments(raw: unknown): string { + if (typeof raw === 'string') return raw; + if (raw === undefined || raw === null) return ''; + if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); + if (Array.isArray(raw) || isRecord(raw)) { + return safeJsonStringify(raw); + } + return ''; +} + +export function normalizeAnthropicToolInput(raw: unknown): unknown { + if (raw === undefined || raw === null) return {}; + if (isRecord(raw) || Array.isArray(raw)) return raw; + if (typeof raw === 'string') return parseJsonString(raw); + if (typeof raw === 'number' || typeof raw === 'boolean') return raw; + return {}; +} + +function normalizeTextCandidate(value: unknown): string { + if (typeof value === 'string') return value; + if (!isRecord(value)) return ''; + return asTrimmedString(value.text ?? value.content ?? value.output_text); +} + +function normalizeToolMessageContent(raw: unknown): string { + if (raw === undefined || raw === null) return ''; + if (typeof raw === 'string') return raw; + if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); + + if (Array.isArray(raw)) { + const textParts = raw + .map((item) => { + if (typeof item === 'string') return item; + if (!isRecord(item)) return ''; + return normalizeTextCandidate(item); + }) + .filter((item) => item.length > 0); + if (textParts.length > 0) return textParts.join('\n'); + return safeJsonStringify(raw); + } + + if (isRecord(raw)) { + const text = normalizeTextCandidate(raw); + return text || safeJsonStringify(raw); + } + + return ''; +} + +function maybeParseDataUrlImage(url: string): Record | null { + const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(url); + if (!match) return null; + return { + type: 'image', + source: { + type: 'base64', + media_type: match[1], + data: match[2], + }, + }; +} + +function toAnthropicImageBlock(item: Record): Record | null { + const rawImageUrl = item.image_url ?? item.url; + if (typeof rawImageUrl === 'string' && rawImageUrl.trim()) { + const parsed = maybeParseDataUrlImage(rawImageUrl.trim()); + if (parsed) return parsed; + return { + type: 'image', + source: { + type: 'url', + url: rawImageUrl.trim(), + }, + }; + } + + if (isRecord(rawImageUrl)) { + const nestedUrl = asTrimmedString(rawImageUrl.url ?? rawImageUrl.image_url); + if (!nestedUrl) return null; + const parsed = maybeParseDataUrlImage(nestedUrl); + if (parsed) return parsed; + return { + type: 'image', + source: { + type: 'url', + url: nestedUrl, + }, + }; + } + + return null; +} + +function toAnthropicTextBlock(text: string): Record | null { + const normalized = text.trim(); + return normalized ? { type: 'text', text: normalized } : null; +} + +function sanitizeAnthropicContentBlock(item: Record): Record | null { + const type = asTrimmedString(item.type).toLowerCase(); + if (!type) { + return toAnthropicTextBlock(normalizeTextCandidate(item)); + } + + if (type === 'text' || type === 'input_text' || type === 'output_text') { + const textBlock = toAnthropicTextBlock(normalizeTextCandidate(item)); + if (!textBlock) return null; + const next: Record = { ...item, ...textBlock, type: 'text' }; + const cacheControl = sanitizeCacheControl(item.cache_control); + if (cacheControl) next.cache_control = cacheControl; + else delete next.cache_control; + return next; + } + + if (type === 'image_url' || type === 'input_image') { + return toAnthropicImageBlock(item); + } + + if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') { + const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data); + if (!text) return null; + const next: Record = { ...item }; + if (type === 'redacted_thinking') { + next.type = 'redacted_thinking'; + next.data = text; + delete next.thinking; + } else { + next.type = type === 'reasoning' ? 'thinking' : type; + next.thinking = text; + } + delete next.cache_control; + delete next.text; + delete next.content; + return next; + } + + if (type === 'tool_result') { + const toolUseId = asTrimmedString(item.tool_use_id ?? item.toolUseId); + const content = normalizeToolMessageContent(item.content ?? item.result); + if (!toolUseId || !content) return null; + const next: Record = { + type: 'tool_result', + tool_use_id: toolUseId, + content, + }; + const cacheControl = sanitizeCacheControl(item.cache_control); + if (cacheControl) next.cache_control = cacheControl; + return next; + } + + if (type === 'tool_use') { + const id = asTrimmedString(item.id); + const name = asTrimmedString(item.name ?? item.toolName); + if (!id || !name) return null; + const next: Record = { + type: 'tool_use', + id, + name, + input: normalizeAnthropicToolInput(item.input ?? item.arguments ?? item.argumentsText), + }; + const cacheControl = sanitizeCacheControl(item.cache_control); + if (cacheControl) next.cache_control = cacheControl; + return next; + } + + return { ...item }; +} + +function isCacheableAnthropicMessageBlock(item: Record): boolean { + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'thinking' || type === 'redacted_thinking') return false; + if (type === 'text' || type === 'input_text' || type === 'output_text') { + return normalizeTextCandidate(item).trim().length > 0; + } + return true; +} + +function clearAnthropicCacheControls(body: Record) { + if (Array.isArray(body.tools)) { + body.tools = body.tools.map((tool) => { + if (!isRecord(tool)) return tool; + const next = { ...tool }; + delete next.cache_control; + return next; + }); + } + + if (Array.isArray(body.system)) { + body.system = body.system.map((entry) => { + if (!isRecord(entry)) return entry; + const next = { ...entry }; + delete next.cache_control; + return next; + }); + } + + const rawMessages = Array.isArray(body.messages) ? body.messages : []; + body.messages = rawMessages.map((message) => { + if (!isRecord(message)) return message; + const next = { ...message }; + if (Array.isArray(next.content)) { + next.content = next.content.map((item) => { + if (!isRecord(item)) return item; + const block = { ...item }; + delete block.cache_control; + return block; + }); + } + return next; + }); +} + +function ensureStructuralAnthropicCacheControls(body: Record): number { + let count = 0; + + if (Array.isArray(body.tools) && body.tools.length > 0) { + const lastIndex = body.tools.length - 1; + const lastTool = body.tools[lastIndex]; + if (isRecord(lastTool)) { + body.tools[lastIndex] = { + ...lastTool, + cache_control: { type: 'ephemeral' }, + }; + count += 1; + } + } + + if (Array.isArray(body.system) && body.system.length > 0) { + const lastIndex = body.system.length - 1; + const lastPrompt = body.system[lastIndex]; + if (isRecord(lastPrompt)) { + body.system[lastIndex] = { + ...lastPrompt, + cache_control: { type: 'ephemeral' }, + }; + count += 1; + } + } + + return count; +} + +type AnthropicCacheRef = { + get value(): Record; + set value(next: Record); +}; + +function collectAnthropicCacheableMessageRefs(body: Record): AnthropicCacheRef[] { + const refs: AnthropicCacheRef[] = []; + const messages = Array.isArray(body.messages) ? body.messages : []; + + messages.forEach((message, messageIndex) => { + if (!isRecord(message) || !Array.isArray(message.content)) return; + message.content.forEach((item, contentIndex) => { + if (!isRecord(item) || !isCacheableAnthropicMessageBlock(item)) return; + refs.push({ + get value() { + const currentMessages = Array.isArray(body.messages) ? body.messages : []; + const currentMessage = currentMessages[messageIndex]; + if (!isRecord(currentMessage) || !Array.isArray(currentMessage.content)) return item; + const currentItem = currentMessage.content[contentIndex]; + return isRecord(currentItem) ? currentItem : item; + }, + set value(next: Record) { + const currentMessages = Array.isArray(body.messages) ? [...body.messages] : []; + const currentMessage = currentMessages[messageIndex]; + if (!isRecord(currentMessage) || !Array.isArray(currentMessage.content)) return; + const nextContent = [...currentMessage.content]; + nextContent[contentIndex] = next; + currentMessages[messageIndex] = { + ...currentMessage, + content: nextContent, + }; + body.messages = currentMessages; + }, + }); + }); + }); + + return refs; +} + +function addEphemeralCacheControl(item: Record): Record { + return { + ...item, + cache_control: { type: 'ephemeral' }, + }; +} + +function sanitizeUnsupportedAnthropicCacheControls(body: Record) { + const rawMessages = Array.isArray(body.messages) ? body.messages : []; + body.messages = rawMessages.map((message) => { + if (!isRecord(message) || !Array.isArray(message.content)) return message; + return { + ...message, + content: message.content.map((item) => { + if (!isRecord(item)) return item; + if (isCacheableAnthropicMessageBlock(item)) return item; + const next = { ...item }; + delete next.cache_control; + return next; + }), + }; + }); +} + +function optimizeAnthropicCacheControls(body: Record) { + clearAnthropicCacheControls(body); + + const structuralAnchors = ensureStructuralAnthropicCacheControls(body); + const remaining = Math.max(0, 4 - structuralAnchors); + if (remaining <= 0) { + sanitizeUnsupportedAnthropicCacheControls(body); + return; + } + + const refs = collectAnthropicCacheableMessageRefs(body); + if (refs.length <= 0) { + sanitizeUnsupportedAnthropicCacheControls(body); + return; + } + + const desiredMessageAnchors = refs.length >= 20 ? 2 : 1; + const targetAnchors = Math.min(desiredMessageAnchors, remaining); + const usedIndexes = new Set(); + + const applyAnchor = (index: number) => { + if (index < 0 || index >= refs.length || usedIndexes.has(index)) return; + usedIndexes.add(index); + refs[index].value = addEphemeralCacheControl(refs[index].value); + }; + + applyAnchor(refs.length - 1); + + if (targetAnchors > 1) { + const targetIndex = Math.max(refs.length - 1 - 20, 0); + let chosen = -1; + for (let index = targetIndex; index >= 0; index -= 1) { + if (!usedIndexes.has(index)) { + chosen = index; + break; + } + } + if (chosen < 0) { + for (let index = targetIndex + 1; index < refs.length; index += 1) { + if (!usedIndexes.has(index)) { + chosen = index; + break; + } + } + } + applyAnchor(chosen); + } + + sanitizeUnsupportedAnthropicCacheControls(body); +} + +function convertOpenAiContentToAnthropicBlocks(content: unknown): Array> { + if (typeof content === 'string') { + const textBlock = toAnthropicTextBlock(content); + return textBlock ? [textBlock] : []; + } + + if (!Array.isArray(content)) { + if (isRecord(content)) { + const single = sanitizeAnthropicContentBlock(content); + return single ? [single] : []; + } + return []; + } + + return content + .map((item) => { + if (typeof item === 'string') return toAnthropicTextBlock(item); + if (!isRecord(item)) return null; + return sanitizeAnthropicContentBlock(item); + }) + .filter((item): item is Record => !!item); +} + +export function sanitizeAnthropicMessagesBody( + body: Record, + options: { autoOptimizeCacheControls?: boolean } = {}, +): Record { + const sanitized: Record = { ...body }; + const autoOptimizeCacheControls = options.autoOptimizeCacheControls !== false; + const hasTemperature = toFiniteNumber(sanitized.temperature) !== null; + const hasTopP = toFiniteNumber(sanitized.top_p) !== null; + if (hasTemperature && hasTopP) { + delete sanitized.top_p; + } + + const thinkingResult = sanitizeAnthropicThinkingConfig(sanitized.thinking); + if (thinkingResult.value) { + sanitized.thinking = thinkingResult.value; + } else { + delete sanitized.thinking; + } + + const outputConfigResult = sanitizeAnthropicOutputConfig(sanitized.output_config ?? sanitized.outputConfig); + if (outputConfigResult.value) { + sanitized.output_config = outputConfigResult.value; + } else { + delete sanitized.output_config; + } + delete sanitized.outputConfig; + + const toolChoiceResult = sanitizeAnthropicToolChoiceValue(sanitized.tool_choice ?? sanitized.toolChoice); + if (toolChoiceResult.value) { + sanitized.tool_choice = toolChoiceResult.value; + } else { + delete sanitized.tool_choice; + } + delete sanitized.toolChoice; + + const rawMessages = Array.isArray(sanitized.messages) ? sanitized.messages : []; + if (rawMessages.length > 0) { + sanitized.messages = rawMessages.map((message) => { + if (!isRecord(message)) return message; + const next = { ...message }; + if (Array.isArray(next.content)) { + next.content = next.content + .map((item) => (isRecord(item) ? sanitizeAnthropicContentBlock(item) : item)) + .filter((item) => item !== null); + } + return next; + }); + } + + if (autoOptimizeCacheControls) { + optimizeAnthropicCacheControls(sanitized); + } else { + sanitizeUnsupportedAnthropicCacheControls(sanitized); + } + + return sanitized; +} + +function resolveOpenAiReasoningSettings( + openaiBody: Record, +): { thinking?: Record; outputConfig?: Record } { + const explicitReasoning = isRecord(openaiBody.reasoning) ? openaiBody.reasoning : null; + const reasoningEffort = asTrimmedString( + openaiBody.reasoning_effort + ?? explicitReasoning?.effort + ?? explicitReasoning?.reasoning_effort, + ).toLowerCase(); + const reasoningBudget = toPositiveInteger( + openaiBody.reasoning_budget + ?? explicitReasoning?.budget_tokens + ?? explicitReasoning?.max_tokens, + ); + + const result: { thinking?: Record; outputConfig?: Record } = {}; + + if (reasoningBudget) { + result.thinking = { + type: 'enabled', + budget_tokens: reasoningBudget, + }; + } else if (reasoningEffort && VALID_ANTHROPIC_EFFORTS.has(reasoningEffort)) { + result.thinking = { type: 'adaptive' }; + } + + if (reasoningEffort && VALID_ANTHROPIC_EFFORTS.has(reasoningEffort)) { + result.outputConfig = { effort: reasoningEffort }; + } + + return result; +} + +export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown { + if (!Array.isArray(rawTools)) return rawTools; + + const converted = rawTools + .map((item) => { + if (!isRecord(item)) return null; + + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'function' && isRecord(item.function)) { + const fn = item.function; + const name = asTrimmedString(fn.name); + if (!name) return null; + + const mapped: Record = { name }; + const description = asTrimmedString(fn.description); + if (description) mapped.description = description; + if (fn.parameters !== undefined) mapped.input_schema = fn.parameters; + if (item.cache_control !== undefined) mapped.cache_control = item.cache_control; + return mapped; + } + + if (asTrimmedString(item.name) && item.input_schema !== undefined) { + return item; + } + + return null; + }) + .filter((item): item is Record => !!item); + + return converted.length > 0 ? converted : rawTools; +} + +export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown { + if (rawToolChoice === undefined) return undefined; + + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'required') return { type: 'any' }; + if (normalized === 'none') return { type: 'none' }; + if (normalized === 'auto') return { type: 'auto' }; + if (normalized === 'any') return { type: 'any' }; + return rawToolChoice; + } + + if (!isRecord(rawToolChoice)) return rawToolChoice; + + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'function' && isRecord(rawToolChoice.function)) { + const name = asTrimmedString(rawToolChoice.function.name); + return name ? { type: 'tool', name } : { type: 'any' }; + } + + if (type === 'tool' || type === 'auto' || type === 'any' || type === 'none') { + return rawToolChoice; + } + + return rawToolChoice; +} + +export function convertOpenAiBodyToAnthropicMessagesBody( + openaiBody: Record, + modelName: string, + stream: boolean, +): Record { + const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; + const systemContents: string[] = []; + const messages: Array<{ + role: 'user' | 'assistant'; + content: string | Array>; + }> = []; + + const appendAssistantMessage = (item: Record) => { + const role = asTrimmedString(item.role).toLowerCase() || 'assistant'; + if (role !== 'assistant') return; + + const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content); + + const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; + for (let index = 0; index < rawToolCalls.length; index += 1) { + const toolCall = rawToolCalls[index]; + if (!isRecord(toolCall)) continue; + const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; + const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`; + const name = ( + asTrimmedString(functionPart.name) + || asTrimmedString(toolCall.name) + || `tool_${index}` + ); + contentBlocks.push({ + type: 'tool_use', + id, + name, + input: normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments), + }); + } + + if (contentBlocks.length <= 0) return; + if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') { + const singleText = asTrimmedString(contentBlocks[0].text); + if (singleText) { + messages.push({ role: 'assistant', content: singleText }); + } + return; + } + + messages.push({ + role: 'assistant', + content: contentBlocks, + }); + }; + + for (let messageIndex = 0; messageIndex < rawMessages.length; messageIndex += 1) { + const item = rawMessages[messageIndex]; + if (!isRecord(item)) continue; + const role = asTrimmedString(item.role).toLowerCase() || 'user'; + + if (role === 'system' || role === 'developer') { + const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content); + const text = contentBlocks + .filter((block) => block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text as string) + .filter(Boolean) + .join('\n\n'); + if (text) systemContents.push(text); + continue; + } + + if (role === 'tool') { + const toolResultBlocks: Array> = []; + let cursor = messageIndex; + while (cursor < rawMessages.length) { + const toolCandidate = rawMessages[cursor]; + if (!isRecord(toolCandidate)) break; + const toolRole = asTrimmedString(toolCandidate.role).toLowerCase(); + if (toolRole !== 'tool') break; + + const toolUseId = ( + asTrimmedString(toolCandidate.tool_call_id) + || asTrimmedString(toolCandidate.id) + ); + const toolResultContent = normalizeToolMessageContent(toolCandidate.content).trim(); + if (toolUseId && toolResultContent) { + toolResultBlocks.push({ + type: 'tool_result', + tool_use_id: toolUseId, + content: toolResultContent, + }); + } + cursor += 1; + } + + if (toolResultBlocks.length > 0 && cursor < rawMessages.length) { + const nextItem = rawMessages[cursor]; + if (isRecord(nextItem) && asTrimmedString(nextItem.role).toLowerCase() === 'user') { + const userBlocks = convertOpenAiContentToAnthropicBlocks(nextItem.content); + toolResultBlocks.push(...userBlocks); + cursor += 1; + } + } + + if (toolResultBlocks.length > 0) { + messages.push({ + role: 'user', + content: toolResultBlocks, + }); + } + + messageIndex = cursor - 1; + continue; + } + + if (role === 'assistant') { + appendAssistantMessage(item); + continue; + } + + const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content); + if (contentBlocks.length <= 0) continue; + + if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') { + const singleText = asTrimmedString(contentBlocks[0].text); + if (singleText) { + messages.push({ role: 'user', content: singleText }); + } + continue; + } + + messages.push({ + role: 'user', + content: contentBlocks, + }); + } + + const body: Record = { + model: modelName, + stream, + messages, + max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096, + }; + + if (systemContents.length > 0) { + body.system = systemContents.join('\n\n'); + } + + const temperature = toFiniteNumber(openaiBody.temperature); + if (temperature !== null) body.temperature = temperature; + + const topP = toFiniteNumber(openaiBody.top_p); + if (topP !== null) body.top_p = topP; + + if (Array.isArray(openaiBody.stop) && openaiBody.stop.length > 0) { + body.stop_sequences = openaiBody.stop; + } + + if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools); + + const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice); + if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice; + + const reasoningSettings = resolveOpenAiReasoningSettings(openaiBody); + if (reasoningSettings.thinking) body.thinking = reasoningSettings.thinking; + if (reasoningSettings.outputConfig) body.output_config = reasoningSettings.outputConfig; + + return sanitizeAnthropicMessagesBody(body, { autoOptimizeCacheControls: false }); +} + +export function validateAnthropicMessagesBody( + body: Record, +): { sanitizedBody?: Record; error?: { statusCode: number; payload: unknown } } { + const thinkingResult = sanitizeAnthropicThinkingConfig(body.thinking); + if (thinkingResult.error) { + return { + error: { + statusCode: 400, + payload: { error: { message: thinkingResult.error, type: 'invalid_request_error' } }, + }, + }; + } + + const outputConfigResult = sanitizeAnthropicOutputConfig(body.output_config ?? body.outputConfig); + if (outputConfigResult.error) { + return { + error: { + statusCode: 400, + payload: { error: { message: outputConfigResult.error, type: 'invalid_request_error' } }, + }, + }; + } + + const toolChoiceResult = sanitizeAnthropicToolChoiceValue(body.tool_choice ?? body.toolChoice); + if (toolChoiceResult.error) { + return { + error: { + statusCode: 400, + payload: { error: { message: toolChoiceResult.error, type: 'invalid_request_error' } }, + }, + }; + } + + return { sanitizedBody: sanitizeAnthropicMessagesBody(body) }; +} diff --git a/src/server/transformers/anthropic/messages/inbound.ts b/src/server/transformers/anthropic/messages/inbound.ts new file mode 100644 index 0000000..28b09f1 --- /dev/null +++ b/src/server/transformers/anthropic/messages/inbound.ts @@ -0,0 +1,22 @@ +import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js'; +import { validateAnthropicMessagesBody } from './conversion.js'; + +export const anthropicMessagesInbound = { + parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } { + const parsed = parseDownstreamChatRequest(body, 'claude'); + if (parsed.error || !parsed.value) return parsed; + + const originalBody = parsed.value.claudeOriginalBody; + if (originalBody) { + const validation = validateAnthropicMessagesBody(originalBody); + if (validation.error) { + return { error: validation.error }; + } + if (validation.sanitizedBody) { + parsed.value.claudeOriginalBody = validation.sanitizedBody; + } + } + + return parsed; + }, +}; diff --git a/src/server/transformers/anthropic/messages/index.ts b/src/server/transformers/anthropic/messages/index.ts new file mode 100644 index 0000000..c1bd871 --- /dev/null +++ b/src/server/transformers/anthropic/messages/index.ts @@ -0,0 +1,95 @@ +import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js'; +import { anthropicMessagesInbound } from './inbound.js'; +import { anthropicMessagesOutbound } from './outbound.js'; +import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js'; +import { anthropicMessagesUsage } from './usage.js'; +import { createAnthropicMessagesAggregateState } from './aggregator.js'; +export { + ANTHROPIC_RAW_SSE_EVENT_NAMES, + consumeAnthropicSseEvent, + isAnthropicRawSseEventName, + serializeAnthropicFinalAsStream, + serializeAnthropicUpstreamFinalAsStream, + serializeAnthropicRawSseEvent, + syncAnthropicRawStreamStateFromEvent, +} from './stream.js'; + +export const anthropicMessagesTransformer = { + protocol: 'anthropic/messages' as const, + inbound: anthropicMessagesInbound, + outbound: anthropicMessagesOutbound, + stream: anthropicMessagesStream, + usage: anthropicMessagesUsage, + aggregator: { + createState: createAnthropicMessagesAggregateState, + }, + transformRequest(body: unknown): ReturnType { + return anthropicMessagesInbound.parse(body); + }, + createStreamContext(modelName: string): StreamTransformContext { + return anthropicMessagesStream.createContext(modelName); + }, + createDownstreamContext(): ClaudeDownstreamContext { + return anthropicMessagesStream.createDownstreamContext(); + }, + transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse { + return anthropicMessagesOutbound.normalizeFinal(payload, modelName, fallbackText); + }, + transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent { + return anthropicMessagesStream.normalizeEvent(payload, context, modelName); + }, + serializeStreamEvent( + event: NormalizedStreamEvent, + context: StreamTransformContext, + claudeContext: ClaudeDownstreamContext, + ): string[] { + return anthropicMessagesStream.serializeEvent(event, context, claudeContext); + }, + serializeDone( + context: StreamTransformContext, + claudeContext: ClaudeDownstreamContext, + ): string[] { + return anthropicMessagesStream.serializeDone(context, claudeContext); + }, + serializeFinalResponse( + normalized: NormalizedFinalResponse, + usage: Parameters[1], + ) { + return anthropicMessagesOutbound.serializeFinal(normalized, usage); + }, + serializeUpstreamFinalAsStream( + payload: unknown, + modelName: string, + fallbackText: string, + streamContext: StreamTransformContext, + claudeContext: ClaudeDownstreamContext, + ) { + return anthropicMessagesStream.serializeUpstreamFinalAsStream( + payload, + modelName, + fallbackText, + anthropicMessagesOutbound.normalizeFinal, + streamContext, + claudeContext, + ); + }, + consumeSseEventBlock( + eventBlock: { event: string; data: string }, + streamContext: StreamTransformContext, + claudeContext: ClaudeDownstreamContext, + modelName: string, + ) { + return consumeAnthropicSseEvent( + eventBlock, + streamContext, + claudeContext, + modelName, + ); + }, + pullSseEvents(buffer: string) { + return anthropicMessagesStream.pullSseEvents(buffer); + }, +}; + +export type AnthropicMessagesTransformer = typeof anthropicMessagesTransformer; +export type AnthropicMessagesParsedRequest = ParsedDownstreamChatRequest; diff --git a/src/server/transformers/anthropic/messages/outbound.ts b/src/server/transformers/anthropic/messages/outbound.ts new file mode 100644 index 0000000..571fc02 --- /dev/null +++ b/src/server/transformers/anthropic/messages/outbound.ts @@ -0,0 +1,10 @@ +import { normalizeUpstreamFinalResponse, serializeFinalResponse, type NormalizedFinalResponse } from '../../shared/normalized.js'; + +export const anthropicMessagesOutbound = { + normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse { + return normalizeUpstreamFinalResponse(payload, modelName, fallbackText); + }, + serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) { + return serializeFinalResponse('claude', normalized, usage as any); + }, +}; diff --git a/src/server/transformers/anthropic/messages/stream.ts b/src/server/transformers/anthropic/messages/stream.ts new file mode 100644 index 0000000..f76657f --- /dev/null +++ b/src/server/transformers/anthropic/messages/stream.ts @@ -0,0 +1,700 @@ +import { + createClaudeDownstreamContext, + createStreamTransformContext, + normalizeStopReason, + normalizeUpstreamStreamEvent, + pullSseEventsWithDone, + type ClaudeDownstreamContext, + type NormalizedFinalResponse, + type NormalizedStreamEvent, + type StreamTransformContext, +} from '../../shared/normalized.js'; +import { type AnthropicExtendedStreamEvent } from './aggregator.js'; + +type AnthropicStreamPayload = Record; + +type ExtendedClaudeDownstreamContext = ClaudeDownstreamContext & { + thinkingBlockIndex?: number | null; + pendingSignature?: string | null; +}; + +export const ANTHROPIC_RAW_SSE_EVENT_NAMES = new Set([ + 'message_start', + 'content_block_start', + 'content_block_delta', + 'content_block_stop', + 'message_delta', + 'message_stop', + 'ping', + 'error', +]); + +function isRecord(value: unknown): value is AnthropicStreamPayload { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function serializeSse(event: string, payload: Record): string { + return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; +} + +export function isAnthropicRawSseEventName(value: unknown): value is string { + return typeof value === 'string' && ANTHROPIC_RAW_SSE_EVENT_NAMES.has(value); +} + +export function serializeAnthropicRawSseEvent(event: string, data: string): string { + const dataLines = data.split('\n').map((line) => `data: ${line}`).join('\n'); + if (event) { + return `event: ${event}\n${dataLines}\n\n`; + } + return `${dataLines}\n\n`; +} + +function ensureContext(context: ClaudeDownstreamContext): ExtendedClaudeDownstreamContext { + const extended = context as ExtendedClaudeDownstreamContext; + if (extended.thinkingBlockIndex === undefined) extended.thinkingBlockIndex = null; + if (extended.pendingSignature === undefined) extended.pendingSignature = null; + return extended; +} + +export function syncAnthropicRawStreamStateFromEvent( + eventName: string, + parsedPayload: unknown, + streamContext: StreamTransformContext, + downstreamContext: ClaudeDownstreamContext, +) { + const context = ensureContext(downstreamContext); + if (eventName === 'message_start') { + context.messageStarted = true; + if (isRecord(parsedPayload) && isRecord(parsedPayload.message)) { + const message = parsedPayload.message; + if (typeof message.id === 'string' && message.id.trim().length > 0) { + streamContext.id = message.id; + } + if (typeof message.model === 'string' && message.model.trim().length > 0) { + streamContext.model = message.model; + } + } + return; + } + + if (eventName === 'content_block_start') { + context.contentBlockStarted = true; + return; + } + + if (eventName === 'content_block_stop') { + context.contentBlockStarted = false; + return; + } + + if (eventName === 'message_stop') { + context.doneSent = true; + } +} + +function buildClaudeMessageId(sourceId: string): string { + if (sourceId.startsWith('msg_')) return sourceId; + const sanitized = sourceId.replace(/[^A-Za-z0-9_-]/g, '_'); + return `msg_${sanitized || Date.now()}`; +} + +function ensureClaudeStartEvents( + streamContext: StreamTransformContext, + context: ExtendedClaudeDownstreamContext, +): string[] { + if (context.messageStarted) return []; + context.messageStarted = true; + return [ + serializeSse('message_start', { + type: 'message_start', + message: { + id: buildClaudeMessageId(streamContext.id), + type: 'message', + role: 'assistant', + model: streamContext.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + }, + }), + ]; +} + +function closeTextBlock(context: ExtendedClaudeDownstreamContext): string[] { + if (context.textBlockIndex === null || context.textBlockIndex === undefined) return []; + const index = context.textBlockIndex; + context.textBlockIndex = null; + context.contentBlockStarted = false; + return [ + serializeSse('content_block_stop', { + type: 'content_block_stop', + index, + }), + ]; +} + +function emitPendingSignature(context: ExtendedClaudeDownstreamContext): string[] { + if (!context.pendingSignature || context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) { + return []; + } + const signature = context.pendingSignature; + context.pendingSignature = null; + return [ + serializeSse('content_block_delta', { + type: 'content_block_delta', + index: context.thinkingBlockIndex, + delta: { + type: 'signature_delta', + signature, + }, + }), + ]; +} + +function closeThinkingBlock(context: ExtendedClaudeDownstreamContext): string[] { + if (context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) return []; + const index = context.thinkingBlockIndex; + const events = [ + ...emitPendingSignature(context), + serializeSse('content_block_stop', { + type: 'content_block_stop', + index, + }), + ]; + context.thinkingBlockIndex = null; + return events; +} + +function closeToolBlocks(context: ExtendedClaudeDownstreamContext): string[] { + const openBlocks = Object.values(context.toolBlocks) + .filter((item) => item.open) + .sort((a, b) => a.contentIndex - b.contentIndex); + + if (openBlocks.length <= 0) return []; + + const events: string[] = []; + for (const block of openBlocks) { + block.open = false; + events.push(serializeSse('content_block_stop', { + type: 'content_block_stop', + index: block.contentIndex, + })); + } + return events; +} + +function closeAllBlocks(context: ExtendedClaudeDownstreamContext): string[] { + return [ + ...closeTextBlock(context), + ...closeThinkingBlock(context), + ...closeToolBlocks(context), + ]; +} + +function ensureTextBlockStart(context: ExtendedClaudeDownstreamContext): string[] { + if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return []; + const index = context.nextContentBlockIndex; + context.nextContentBlockIndex += 1; + context.textBlockIndex = index; + context.contentBlockStarted = true; + return [ + serializeSse('content_block_start', { + type: 'content_block_start', + index, + content_block: { + type: 'text', + text: '', + }, + }), + ]; +} + +function ensureThinkingBlockStart(context: ExtendedClaudeDownstreamContext): string[] { + if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) return []; + const index = context.nextContentBlockIndex; + context.nextContentBlockIndex += 1; + context.thinkingBlockIndex = index; + return [ + serializeSse('content_block_start', { + type: 'content_block_start', + index, + content_block: { + type: 'thinking', + thinking: '', + }, + }), + ]; +} + +function ensureToolBlockStart( + context: ExtendedClaudeDownstreamContext, + toolDelta: NonNullable[number], +): { events: string[]; contentIndex: number } { + const toolSlot = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0; + let state = context.toolBlocks[toolSlot]; + if (!state) { + state = { + contentIndex: context.nextContentBlockIndex, + id: toolDelta.id || `toolu_${toolSlot}`, + name: toolDelta.name || `tool_${toolSlot}`, + open: false, + }; + context.nextContentBlockIndex += 1; + context.toolBlocks[toolSlot] = state; + } else { + if (toolDelta.id) state.id = toolDelta.id; + if (toolDelta.name) state.name = toolDelta.name; + } + + const events: string[] = []; + if (!state.open) { + state.open = true; + events.push(serializeSse('content_block_start', { + type: 'content_block_start', + index: state.contentIndex, + content_block: { + type: 'tool_use', + id: state.id, + name: state.name, + input: {}, + }, + })); + } + + return { events, contentIndex: state.contentIndex }; +} + +function buildDoneEvents( + streamContext: StreamTransformContext, + context: ExtendedClaudeDownstreamContext, + finishReason?: string | null, +): string[] { + if (context.doneSent) return []; + + const events = [ + ...ensureClaudeStartEvents(streamContext, context), + ...closeAllBlocks(context), + serializeSse('message_delta', { + type: 'message_delta', + delta: { + stop_reason: toClaudeStopReason(finishReason), + stop_sequence: null, + }, + usage: { + output_tokens: 0, + }, + }), + serializeSse('message_stop', { + type: 'message_stop', + }), + ]; + context.doneSent = true; + return events; +} + +export function serializeAnthropicFinalAsStream( + normalizedFinal: NormalizedFinalResponse, + streamContext: StreamTransformContext, + downstreamContext: ClaudeDownstreamContext, +): string[] { + streamContext.id = normalizedFinal.id; + streamContext.model = normalizedFinal.model; + streamContext.created = normalizedFinal.created; + + const lines = [ + ...anthropicMessagesStream.serializeEvent({ role: 'assistant' }, streamContext, downstreamContext), + ]; + + if (normalizedFinal.reasoningContent) { + lines.push( + ...anthropicMessagesStream.serializeEvent( + { reasoningDelta: normalizedFinal.reasoningContent }, + streamContext, + downstreamContext, + ), + ); + } + + if (normalizedFinal.content) { + lines.push( + ...anthropicMessagesStream.serializeEvent( + { contentDelta: normalizedFinal.content }, + streamContext, + downstreamContext, + ), + ); + } + + lines.push( + ...anthropicMessagesStream.serializeEvent( + { finishReason: normalizedFinal.finishReason }, + streamContext, + downstreamContext, + ), + ); + + return lines; +} + +export function serializeAnthropicUpstreamFinalAsStream( + payload: unknown, + modelName: string, + fallbackText: string, + normalizeFinal: (payload: unknown, modelName: string, fallbackText?: string) => NormalizedFinalResponse, + streamContext: StreamTransformContext, + downstreamContext: ClaudeDownstreamContext, +): string[] { + const normalizedFinal = normalizeFinal(payload, modelName, fallbackText); + return serializeAnthropicFinalAsStream(normalizedFinal, streamContext, downstreamContext); +} + +function toClaudeStopReason(finishReason: string | null | undefined): string { + const normalized = normalizeStopReason(finishReason); + if (normalized === 'length') return 'max_tokens'; + if (normalized === 'tool_calls') return 'tool_use'; + return 'end_turn'; +} + +function normalizeAnthropicRawEvent( + payload: AnthropicStreamPayload, + context: StreamTransformContext, + fallbackModel: string, +): AnthropicExtendedStreamEvent | null { + const type = asTrimmedString(payload.type); + if (!type) return null; + + if (type === 'message_start' && isRecord(payload.message)) { + const message = payload.message; + if (asTrimmedString(message.id)) context.id = asTrimmedString(message.id); + if (asTrimmedString(message.model)) context.model = asTrimmedString(message.model); + else if (!context.model) context.model = fallbackModel; + return { role: 'assistant' }; + } + + if (type === 'content_block_start' && isRecord(payload.content_block)) { + const contentBlock = payload.content_block; + const blockType = asTrimmedString(contentBlock.type); + const index = typeof payload.index === 'number' ? payload.index : undefined; + + if (blockType === 'thinking') { + return { + anthropic: { + startBlock: { + kind: 'thinking', + index, + }, + }, + }; + } + + if (blockType === 'redacted_thinking') { + return { + anthropic: { + startBlock: { + kind: 'redacted_thinking', + index, + }, + redactedThinkingData: asTrimmedString(contentBlock.data), + }, + }; + } + + if (blockType === 'tool_use') { + return { + toolCallDeltas: [{ + index: typeof index === 'number' ? index : 0, + id: asTrimmedString(contentBlock.id) || undefined, + name: asTrimmedString(contentBlock.name) || undefined, + }], + }; + } + } + + if (type === 'content_block_delta' && isRecord(payload.delta)) { + const delta = payload.delta; + const deltaType = asTrimmedString(delta.type); + const index = typeof payload.index === 'number' ? payload.index : 0; + + if (deltaType === 'thinking_delta') { + return { + reasoningDelta: asTrimmedString(delta.thinking ?? delta.text) || undefined, + }; + } + + if (deltaType === 'signature_delta') { + return { + anthropic: { + signatureDelta: asTrimmedString(delta.signature) || undefined, + }, + }; + } + + if (deltaType === 'text_delta') { + return { + contentDelta: asTrimmedString(delta.text) || undefined, + }; + } + + if (deltaType === 'input_json_delta') { + return { + toolCallDeltas: [{ + index, + argumentsDelta: asTrimmedString(delta.partial_json), + }], + }; + } + } + + if (type === 'content_block_stop') { + return { + anthropic: { + stopBlockIndex: typeof payload.index === 'number' ? payload.index : null, + }, + }; + } + + if (type === 'message_delta' && isRecord(payload.delta)) { + return { + finishReason: normalizeStopReason(payload.delta.stop_reason ?? payload.stop_reason), + }; + } + + if (type === 'message_stop') { + return { done: true }; + } + + return null; +} + +export type AnthropicConsumedSseEvent = { + handled: boolean; + lines: string[]; + done: boolean; + parsedPayload: unknown | null; +}; + +export function consumeAnthropicSseEvent( + eventBlock: { event: string; data: string }, + streamContext: StreamTransformContext, + downstreamContext: ClaudeDownstreamContext, + fallbackModel: string, +): AnthropicConsumedSseEvent { + const context = ensureContext(downstreamContext); + let parsedPayload: unknown = null; + + try { + parsedPayload = JSON.parse(eventBlock.data); + } catch { + parsedPayload = null; + } + + if (parsedPayload && isRecord(parsedPayload)) { + const payloadType = typeof parsedPayload.type === 'string' ? parsedPayload.type : ''; + const claudeEventName = isAnthropicRawSseEventName(eventBlock.event) + ? eventBlock.event + : (isAnthropicRawSseEventName(payloadType) ? payloadType : ''); + + if (claudeEventName) { + syncAnthropicRawStreamStateFromEvent( + claudeEventName, + parsedPayload, + streamContext, + context, + ); + return { + handled: true, + lines: [serializeAnthropicRawSseEvent(claudeEventName, eventBlock.data)], + done: context.doneSent, + parsedPayload, + }; + } + } + + return { + handled: false, + lines: [], + done: false, + parsedPayload, + }; +} + +export const anthropicMessagesStream = { + createContext(modelName: string): StreamTransformContext { + return createStreamTransformContext(modelName); + }, + createDownstreamContext(): ClaudeDownstreamContext { + return ensureContext(createClaudeDownstreamContext()); + }, + normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): AnthropicExtendedStreamEvent { + if (isRecord(payload)) { + const anthropicEvent = normalizeAnthropicRawEvent(payload, context, modelName); + if (anthropicEvent) return anthropicEvent; + } + return normalizeUpstreamStreamEvent(payload, context, modelName) as AnthropicExtendedStreamEvent; + }, + serializeEvent( + event: NormalizedStreamEvent, + streamContext: StreamTransformContext, + downstreamContext: ClaudeDownstreamContext, + ): string[] { + const context = ensureContext(downstreamContext); + const anthropicEvent = event as AnthropicExtendedStreamEvent; + const events: string[] = []; + + const needsStart = ( + event.role === 'assistant' + || !!event.contentDelta + || !!event.reasoningDelta + || (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) + || !!anthropicEvent.anthropic + || !!event.finishReason + || !!event.done + ); + if (needsStart) { + events.push(...ensureClaudeStartEvents(streamContext, context)); + } + + if (anthropicEvent.anthropic?.signatureDelta) { + if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) { + events.push(serializeSse('content_block_delta', { + type: 'content_block_delta', + index: context.thinkingBlockIndex, + delta: { + type: 'signature_delta', + signature: anthropicEvent.anthropic.signatureDelta, + }, + })); + } else { + context.pendingSignature = anthropicEvent.anthropic.signatureDelta; + } + } + + if (anthropicEvent.anthropic?.redactedThinkingData) { + events.push(...closeAllBlocks(context)); + const index = context.nextContentBlockIndex; + context.nextContentBlockIndex += 1; + events.push(serializeSse('content_block_start', { + type: 'content_block_start', + index, + content_block: { + type: 'redacted_thinking', + data: anthropicEvent.anthropic.redactedThinkingData, + }, + })); + events.push(serializeSse('content_block_stop', { + type: 'content_block_stop', + index, + })); + } + + if (event.reasoningDelta) { + events.push(...closeToolBlocks(context)); + events.push(...closeTextBlock(context)); + events.push(...ensureThinkingBlockStart(context)); + events.push(serializeSse('content_block_delta', { + type: 'content_block_delta', + index: context.thinkingBlockIndex ?? 0, + delta: { + type: 'thinking_delta', + thinking: event.reasoningDelta, + }, + })); + } + + if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { + events.push(...closeTextBlock(context)); + events.push(...closeThinkingBlock(context)); + for (const toolDelta of event.toolCallDeltas) { + const toolBlock = ensureToolBlockStart(context, toolDelta); + events.push(...toolBlock.events); + if (toolDelta.argumentsDelta) { + events.push(serializeSse('content_block_delta', { + type: 'content_block_delta', + index: toolBlock.contentIndex, + delta: { + type: 'input_json_delta', + partial_json: toolDelta.argumentsDelta, + }, + })); + } + } + } + + if (event.contentDelta) { + events.push(...closeToolBlocks(context)); + events.push(...closeThinkingBlock(context)); + events.push(...ensureTextBlockStart(context)); + events.push(serializeSse('content_block_delta', { + type: 'content_block_delta', + index: context.textBlockIndex ?? 0, + delta: { + type: 'text_delta', + text: event.contentDelta, + }, + })); + } + + if ( + anthropicEvent.anthropic?.stopBlockIndex !== undefined + && anthropicEvent.anthropic.stopBlockIndex !== null + ) { + const targetIndex = anthropicEvent.anthropic.stopBlockIndex; + if (context.thinkingBlockIndex === targetIndex) { + events.push(...closeThinkingBlock(context)); + } + if (context.textBlockIndex === targetIndex) { + events.push(...closeTextBlock(context)); + } + const matchingToolBlock = Object.values(context.toolBlocks).find((item) => item.open && item.contentIndex === targetIndex); + if (matchingToolBlock) { + matchingToolBlock.open = false; + events.push(serializeSse('content_block_stop', { + type: 'content_block_stop', + index: matchingToolBlock.contentIndex, + })); + } + } + + if (event.finishReason || event.done) { + events.push(...buildDoneEvents(streamContext, context, event.finishReason)); + } + + return events; + }, + serializeDone( + streamContext: StreamTransformContext, + downstreamContext: ClaudeDownstreamContext, + ): string[] { + const context = ensureContext(downstreamContext); + return buildDoneEvents(streamContext, context, 'stop'); + }, + pullSseEvents(buffer: string) { + return pullSseEventsWithDone(buffer); + }, + consumeAnthropicSseEvent, + serializeUpstreamFinalAsStream( + payload: unknown, + modelName: string, + fallbackText: string, + normalizeFinal: (payload: unknown, modelName: string, fallbackText?: string) => NormalizedFinalResponse, + streamContext: StreamTransformContext, + downstreamContext: ClaudeDownstreamContext, + ) { + return serializeAnthropicUpstreamFinalAsStream( + payload, + modelName, + fallbackText, + normalizeFinal, + streamContext, + downstreamContext, + ); + }, +}; diff --git a/src/server/transformers/anthropic/messages/usage.ts b/src/server/transformers/anthropic/messages/usage.ts new file mode 100644 index 0000000..3ecaed0 --- /dev/null +++ b/src/server/transformers/anthropic/messages/usage.ts @@ -0,0 +1,113 @@ +import { createEmptyNormalizedUsage, mergeNormalizedUsage, type NormalizedUsage } from '../../shared/normalized.js'; + +type AnthropicRecord = Record; + +function isRecord(value: unknown): value is AnthropicRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function toPositiveInt(value: unknown): number { + const numberValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numberValue)) return 0; + return Math.max(0, Math.trunc(numberValue)); +} + +function resolveAnthropicUsageRecord(payload: unknown): AnthropicRecord | null { + if (isRecord(payload) && isRecord(payload.usage)) return payload.usage; + if (isRecord(payload)) return payload; + return null; +} + +export type AnthropicUsageMetadata = { + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + ephemeral5mInputTokens: number; + ephemeral1hInputTokens: number; + promptTokensIncludingCache: number; +}; + +export function extractAnthropicUsage(payload: unknown): NormalizedUsage { + const usageRecord = resolveAnthropicUsageRecord(payload); + if (!usageRecord) { + return createEmptyNormalizedUsage(); + } + + const inputTokens = toPositiveInt(usageRecord.input_tokens ?? usageRecord.inputTokens); + const outputTokens = toPositiveInt(usageRecord.output_tokens ?? usageRecord.outputTokens); + const cacheReadTokens = Math.max( + toPositiveInt(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens), + toPositiveInt(usageRecord.cached_tokens ?? usageRecord.cachedTokens), + ); + const cacheCreationTokens = Math.max( + toPositiveInt(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens), + toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_5m_input_tokens) + + toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_1h_input_tokens), + toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral5mInputTokens) + + toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral1hInputTokens), + ); + + const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens; + const completionTokens = outputTokens; + const totalTokens = Math.max( + toPositiveInt(usageRecord.total_tokens ?? usageRecord.totalTokens), + promptTokens + completionTokens, + ); + + return { + promptTokens, + completionTokens, + totalTokens, + cachedTokens: cacheReadTokens, + reasoningTokens: 0, + audioInputTokens: 0, + audioOutputTokens: 0, + acceptedPredictionTokens: 0, + rejectedPredictionTokens: 0, + }; +} + +export function extractAnthropicUsageMetadata(payload: unknown): AnthropicUsageMetadata { + const usageRecord = resolveAnthropicUsageRecord(payload); + if (!usageRecord) { + return { + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + ephemeral5mInputTokens: 0, + ephemeral1hInputTokens: 0, + promptTokensIncludingCache: 0, + }; + } + + const inputTokens = toPositiveInt(usageRecord.input_tokens ?? usageRecord.inputTokens); + const cacheReadInputTokens = Math.max( + toPositiveInt(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens), + toPositiveInt(usageRecord.cached_tokens ?? usageRecord.cachedTokens), + ); + const ephemeral5mInputTokens = Math.max( + toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_5m_input_tokens), + toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral5mInputTokens), + ); + const ephemeral1hInputTokens = Math.max( + toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_1h_input_tokens), + toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral1hInputTokens), + ); + const cacheCreationInputTokens = Math.max( + toPositiveInt(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens), + ephemeral5mInputTokens + ephemeral1hInputTokens, + ); + + return { + cacheReadInputTokens, + cacheCreationInputTokens, + ephemeral5mInputTokens, + ephemeral1hInputTokens, + promptTokensIncludingCache: inputTokens + cacheReadInputTokens + cacheCreationInputTokens, + }; +} + +export const anthropicMessagesUsage = { + empty: createEmptyNormalizedUsage, + fromPayload: extractAnthropicUsage, + metadataFromPayload: extractAnthropicUsageMetadata, + merge: mergeNormalizedUsage, +}; diff --git a/src/server/transformers/final-hard-cut.architecture.test.ts b/src/server/transformers/final-hard-cut.architecture.test.ts new file mode 100644 index 0000000..bd84ba3 --- /dev/null +++ b/src/server/transformers/final-hard-cut.architecture.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +function readWorkspaceFile(relativePath: string): string { + return readFileSync(path.resolve(process.cwd(), relativePath), 'utf8'); +} + +describe('final transformer hard-cut architecture', () => { + it('keeps shared normalized helpers independent from route chatFormats', () => { + const sharedNormalized = readWorkspaceFile('src/server/transformers/shared/normalized.ts'); + + expect(sharedNormalized).not.toContain("../../routes/proxy/chatFormats.js"); + expect(sharedNormalized).not.toContain("from '../../routes/proxy/chatFormats.js'"); + }); + + it('removes normalizeContentText from upstream endpoint compatibility path', () => { + const upstreamEndpoint = readWorkspaceFile('src/server/routes/proxy/upstreamEndpoint.ts'); + + expect(upstreamEndpoint).not.toContain('function normalizeContentText('); + expect(upstreamEndpoint).not.toContain('normalizeContentText('); + }); + + it('keeps responses protocol shaping out of route-local helpers', () => { + const responsesRoute = readWorkspaceFile('src/server/routes/proxy/responses.ts'); + + expect(responsesRoute).not.toContain('function toResponsesPayload('); + expect(responsesRoute).not.toContain('function createResponsesStreamState('); + }); + + it('replaces gemini passthrough placeholders with protocol-aware helpers', () => { + const geminiInbound = readWorkspaceFile('src/server/transformers/gemini/generate-content/inbound.ts'); + const geminiStream = readWorkspaceFile('src/server/transformers/gemini/generate-content/stream.ts'); + const geminiAggregator = readWorkspaceFile('src/server/transformers/gemini/generate-content/aggregator.ts'); + + expect(geminiInbound).not.toContain('passthrough'); + expect(geminiStream).not.toContain('passthrough'); + expect(geminiAggregator).not.toContain('parts: unknown[]'); + }); +}); diff --git a/src/server/transformers/gemini/generate-content/aggregator.ts b/src/server/transformers/gemini/generate-content/aggregator.ts new file mode 100644 index 0000000..e91fb61 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/aggregator.ts @@ -0,0 +1,182 @@ +type GeminiRecord = Record; + +function isRecord(value: unknown): value is GeminiRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +function pushUniqueJson(target: GeminiRecord[], incoming: unknown): void { + if (!isRecord(incoming)) return; + const serialized = JSON.stringify(incoming); + if (target.some((item) => JSON.stringify(item) === serialized)) return; + target.push(cloneJsonValue(incoming)); +} + +function collectPartsFromPayload(payload: unknown): GeminiRecord[] { + if (Array.isArray(payload)) { + return payload.flatMap((item) => collectPartsFromPayload(item)); + } + if (!isRecord(payload)) return []; + + const candidates = Array.isArray(payload.candidates) ? payload.candidates : []; + const parts: GeminiRecord[] = []; + for (const candidate of candidates) { + if (!isRecord(candidate)) continue; + const content = isRecord(candidate.content) ? candidate.content : null; + if (!content || !Array.isArray(content.parts)) continue; + for (const part of content.parts) { + if (isRecord(part)) parts.push(cloneJsonValue(part)); + } + } + return parts; +} + +function updateIfDefined( + usage: GeminiGenerateContentUsageSummary, + key: T, + value: unknown, +): void { + if (typeof value === 'number' && Number.isFinite(value)) { + usage[key] = value; + } +} + +export type GeminiGenerateContentUsageSummary = { + promptTokenCount?: number | null; + candidatesTokenCount?: number | null; + totalTokenCount?: number | null; + cachedContentTokenCount?: number | null; + thoughtsTokenCount?: number | null; +}; + +export type GeminiGenerateContentCandidateAggregate = { + index: number; + finishReason: string | null; + parts: GeminiRecord[]; + groundingMetadata?: GeminiRecord; + citationMetadata?: GeminiRecord; +}; + +export type GeminiGenerateContentAggregateState = { + responseId: string | null; + modelVersion: string | null; + finishReason: string | null; + parts: GeminiRecord[]; + citations: GeminiRecord[]; + groundingMetadata: GeminiRecord[]; + thoughtSignatures: string[]; + usage: GeminiGenerateContentUsageSummary; + candidates: GeminiGenerateContentCandidateAggregate[]; +}; + +export function createGeminiGenerateContentAggregateState(): GeminiGenerateContentAggregateState { + return { + responseId: null, + modelVersion: null, + finishReason: null, + parts: [], + citations: [], + groundingMetadata: [], + thoughtSignatures: [], + usage: {}, + candidates: [], + }; +} + +function ensureCandidateAggregate( + state: GeminiGenerateContentAggregateState, + rawIndex: unknown, +): GeminiGenerateContentCandidateAggregate { + const normalizedIndex = typeof rawIndex === 'number' && Number.isFinite(rawIndex) + ? Math.max(0, Math.trunc(rawIndex)) + : 0; + let existing = state.candidates.find((candidate) => candidate.index === normalizedIndex); + if (!existing) { + existing = { + index: normalizedIndex, + finishReason: null, + parts: [], + }; + state.candidates.push(existing); + state.candidates.sort((left, right) => left.index - right.index); + } + return existing; +} + +export function applyGeminiGenerateContentAggregate( + state: GeminiGenerateContentAggregateState, + payload: unknown, +): GeminiGenerateContentAggregateState { + for (const part of collectPartsFromPayload(payload)) { + state.parts.push(part); + if (typeof part.thoughtSignature === 'string' && part.thoughtSignature.trim()) { + if (!state.thoughtSignatures.includes(part.thoughtSignature)) { + state.thoughtSignatures.push(part.thoughtSignature); + } + } + } + + const payloads = Array.isArray(payload) ? payload : [payload]; + for (const item of payloads) { + if (!isRecord(item)) continue; + + if (typeof item.responseId === 'string' && item.responseId.trim()) { + state.responseId = item.responseId.trim(); + } + if (typeof item.modelVersion === 'string' && item.modelVersion.trim()) { + state.modelVersion = item.modelVersion.trim(); + } + + const candidates = Array.isArray(item.candidates) ? item.candidates : []; + for (const candidate of candidates) { + if (!isRecord(candidate)) continue; + const candidateAggregate = ensureCandidateAggregate(state, candidate.index); + if (candidate.groundingMetadata !== undefined) { + pushUniqueJson(state.groundingMetadata, candidate.groundingMetadata); + if (isRecord(candidate.groundingMetadata)) { + candidateAggregate.groundingMetadata = cloneJsonValue(candidate.groundingMetadata); + } + } + if (candidate.citationMetadata !== undefined) { + pushUniqueJson(state.citations, candidate.citationMetadata); + if (isRecord(candidate.citationMetadata)) { + candidateAggregate.citationMetadata = cloneJsonValue(candidate.citationMetadata); + } + } + if (typeof candidate.finishReason === 'string' && candidate.finishReason.trim()) { + state.finishReason = candidate.finishReason.trim(); + candidateAggregate.finishReason = candidate.finishReason.trim(); + } + + const content = isRecord(candidate.content) ? candidate.content : null; + if (content && Array.isArray(content.parts)) { + for (const part of content.parts) { + if (!isRecord(part)) continue; + candidateAggregate.parts.push(cloneJsonValue(part)); + } + } + } + + const usageMetadata = isRecord(item.usageMetadata) ? item.usageMetadata : null; + if (usageMetadata) { + updateIfDefined(state.usage, 'promptTokenCount', usageMetadata.promptTokenCount); + updateIfDefined(state.usage, 'candidatesTokenCount', usageMetadata.candidatesTokenCount); + updateIfDefined(state.usage, 'totalTokenCount', usageMetadata.totalTokenCount); + updateIfDefined(state.usage, 'cachedContentTokenCount', usageMetadata.cachedContentTokenCount); + updateIfDefined(state.usage, 'thoughtsTokenCount', usageMetadata.thoughtsTokenCount); + } + } + + return state; +} diff --git a/src/server/transformers/gemini/generate-content/convert.ts b/src/server/transformers/gemini/generate-content/convert.ts new file mode 100644 index 0000000..df18f0e --- /dev/null +++ b/src/server/transformers/gemini/generate-content/convert.ts @@ -0,0 +1,165 @@ +export type GeminiReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'max'; + +type GeminiThinkingConfig = { + thinkingBudget?: number; + thinkingLevel?: string; +}; + +function normalizeReasoningEffort(value: unknown): GeminiReasoningEffort { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if ( + normalized === 'none' + || normalized === 'low' + || normalized === 'medium' + || normalized === 'high' + || normalized === 'max' + ) { + return normalized; + } + return 'medium'; +} + +function normalizeThinkingLevel(value: unknown): GeminiReasoningEffort | null { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized === 'minimal') return 'low'; + if ( + normalized === 'none' + || normalized === 'low' + || normalized === 'medium' + || normalized === 'high' + || normalized === 'max' + ) { + return normalized; + } + return null; +} + +function thinkingBudgetToReasoningEffort(budget: number): GeminiReasoningEffort { + if (budget <= 0) return 'none'; + if (budget <= 1024) return 'low'; + if (budget <= 8192) return 'medium'; + if (budget <= 32768) return 'high'; + return 'max'; +} + +function shouldUseThinkingLevel(model: string, effort: GeminiReasoningEffort): boolean { + if (!/gemini-3-/i.test(model)) return false; + return effort === 'none' || effort === 'low' || effort === 'medium' || effort === 'high'; +} + +function reasoningEffortToThinkingBudget(effort: GeminiReasoningEffort): number { + switch (effort) { + case 'none': + return 0; + case 'low': + return 1024; + case 'high': + return 32768; + case 'max': + return 65536; + case 'medium': + default: + return 8192; + } +} + +export function reasoningEffortToGeminiThinkingConfig( + model: string, + reasoningEffort: unknown, +): GeminiThinkingConfig { + const effort = normalizeReasoningEffort(reasoningEffort); + if (shouldUseThinkingLevel(model, effort)) { + return { thinkingLevel: effort }; + } + return { thinkingBudget: reasoningEffortToThinkingBudget(effort) }; +} + +export function geminiThinkingConfigToReasoning( + thinkingConfig: Record | null | undefined, +): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null { + if (!thinkingConfig || typeof thinkingConfig !== 'object') return null; + + const level = normalizeThinkingLevel(thinkingConfig.thinkingLevel); + if (level) { + return { + reasoningEffort: level, + reasoningBudget: reasoningEffortToThinkingBudget(level), + }; + } + + const budget = typeof thinkingConfig.thinkingBudget === 'number' && Number.isFinite(thinkingConfig.thinkingBudget) + ? Math.max(0, Math.trunc(thinkingConfig.thinkingBudget)) + : null; + if (budget === null) return null; + + return { + reasoningEffort: thinkingBudgetToReasoningEffort(budget), + reasoningBudget: budget, + }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function toFiniteInt(value: unknown): number | null { + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numeric)) return null; + return Math.max(0, Math.trunc(numeric)); +} + +export function resolveGeminiThinkingConfigFromRequest( + model: string, + body: Record, +): GeminiThinkingConfig | null { + const generationConfig = isRecord(body.generationConfig) ? body.generationConfig : null; + const nativeThinkingConfig = isRecord(generationConfig?.thinkingConfig) + ? generationConfig!.thinkingConfig as Record + : null; + + if (nativeThinkingConfig) { + const nativeLevel = normalizeThinkingLevel(nativeThinkingConfig.thinkingLevel); + const nativeBudget = toFiniteInt(nativeThinkingConfig.thinkingBudget); + if (nativeLevel) { + return { thinkingLevel: nativeLevel }; + } + if (nativeBudget !== null) { + return { thinkingBudget: nativeBudget }; + } + } + + const reasoning = isRecord(body.reasoning) ? body.reasoning : null; + const reasoningEffort = ( + typeof body.reasoning_effort === 'string' ? body.reasoning_effort + : (typeof reasoning?.effort === 'string' ? reasoning.effort : undefined) + ); + const reasoningBudget = ( + body.reasoning_budget + ?? reasoning?.budget_tokens + ?? reasoning?.thinkingBudget + ?? reasoning?.max_tokens + ); + + const parsedBudget = toFiniteInt(reasoningBudget); + if (parsedBudget !== null) { + return reasoningEffortToGeminiThinkingConfig(model, thinkingBudgetToReasoningEffort(parsedBudget)); + } + + if (reasoningEffort !== undefined) { + return reasoningEffortToGeminiThinkingConfig(model, reasoningEffort); + } + + return null; +} + +export function extractReasoningMetadataFromGeminiRequest( + body: Record | null | undefined, +): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null { + if (!body) return null; + + const generationConfig = isRecord(body.generationConfig) ? body.generationConfig : null; + const thinkingConfig = isRecord(generationConfig?.thinkingConfig) + ? generationConfig!.thinkingConfig as Record + : null; + return geminiThinkingConfigToReasoning(thinkingConfig); +} diff --git a/src/server/transformers/gemini/generate-content/inbound.ts b/src/server/transformers/gemini/generate-content/inbound.ts new file mode 100644 index 0000000..0dcb041 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/inbound.ts @@ -0,0 +1,119 @@ +type GeminiRecord = Record; +import { resolveGeminiThinkingConfigFromRequest } from './convert.js'; + +function isRecord(value: unknown): value is GeminiRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +function cloneContents(value: unknown): unknown[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .filter((item) => isRecord(item)) + .map((item) => { + const next: GeminiRecord = { ...item }; + if (Array.isArray(item.parts)) { + next.parts = item.parts.map((part) => (isRecord(part) ? cloneJsonValue(part) : part)); + } + return next; + }); +} + +function cloneGenerationConfig(value: unknown): GeminiRecord | undefined { + if (!isRecord(value)) return undefined; + const allowedKeys = [ + 'stopSequences', + 'responseModalities', + 'responseMimeType', + 'responseSchema', + 'candidateCount', + 'maxOutputTokens', + 'temperature', + 'topP', + 'topK', + 'presencePenalty', + 'frequencyPenalty', + 'seed', + 'responseLogprobs', + 'logprobs', + 'thinkingConfig', + 'imageConfig', + ]; + const next: GeminiRecord = {}; + for (const key of allowedKeys) { + if (value[key] !== undefined) next[key] = cloneJsonValue(value[key]); + } + return Object.keys(next).length > 0 ? next : undefined; +} + +function cloneTools(value: unknown): unknown[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .filter((item) => isRecord(item)) + .map((item) => { + const next: GeminiRecord = {}; + if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations); + if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch); + if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext); + if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution); + return Object.keys(next).length > 0 ? next : cloneJsonValue(item); + }); +} + +export type GeminiGenerateContentRequest = GeminiRecord; + +export const geminiGenerateContentInbound = { + normalizeRequest(body: unknown, modelName = ''): GeminiGenerateContentRequest { + if (!isRecord(body)) return {}; + + const next: GeminiGenerateContentRequest = {}; + if (body.contents !== undefined) next.contents = cloneContents(body.contents) ?? cloneJsonValue(body.contents); + if (body.systemInstruction !== undefined) next.systemInstruction = cloneJsonValue(body.systemInstruction); + if (body.cachedContent !== undefined) next.cachedContent = cloneJsonValue(body.cachedContent); + if (body.safetySettings !== undefined) next.safetySettings = cloneJsonValue(body.safetySettings); + if (body.generationConfig !== undefined) { + next.generationConfig = cloneGenerationConfig(body.generationConfig) ?? cloneJsonValue(body.generationConfig); + } + if (body.tools !== undefined) next.tools = cloneTools(body.tools) ?? cloneJsonValue(body.tools); + if (body.toolConfig !== undefined) next.toolConfig = cloneJsonValue(body.toolConfig); + + const derivedThinkingConfig = resolveGeminiThinkingConfigFromRequest( + modelName || (typeof body.model === 'string' ? body.model : ''), + body, + ); + if (derivedThinkingConfig) { + const generationConfig = isRecord(next.generationConfig) + ? { ...next.generationConfig } + : {}; + if (!isRecord(generationConfig.thinkingConfig)) { + generationConfig.thinkingConfig = derivedThinkingConfig; + } + next.generationConfig = generationConfig; + } + + const strippedKeys = new Set([ + 'reasoning', + 'reasoning_effort', + 'reasoning_budget', + ]); + + for (const [key, value] of Object.entries(body)) { + if (strippedKeys.has(key)) continue; + if (next[key] !== undefined) continue; + next[key] = cloneJsonValue(value); + } + + return next; + }, +}; diff --git a/src/server/transformers/gemini/generate-content/index.test.ts b/src/server/transformers/gemini/generate-content/index.test.ts new file mode 100644 index 0000000..8e1f078 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/index.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it } from 'vitest'; + +import { + geminiGenerateContentTransformer, + reasoningEffortToGeminiThinkingConfig, + geminiThinkingConfigToReasoning, +} from './index.js'; +import { extractGeminiUsage } from './usage.js'; +import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js'; + +describe('geminiGenerateContentTransformer.inbound', () => { + it('preserves native Gemini request fields through normalization', () => { + const body = geminiGenerateContentTransformer.inbound.normalizeRequest({ + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + systemInstruction: { + role: 'system', + parts: [{ text: 'system prompt' }], + }, + cachedContent: 'cached/abc', + generationConfig: { + responseModalities: ['TEXT'], + responseMimeType: 'application/json', + responseSchema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + }, + thinkingConfig: { + thinkingBudget: 512, + }, + imageConfig: { + aspectRatio: '1:1', + }, + }, + tools: [ + { + functionDeclarations: [ + { + name: 'lookup', + description: 'find facts', + }, + ], + }, + { googleSearch: {} }, + { urlContext: {} }, + { codeExecution: {} }, + ], + }); + + expect(body).toEqual({ + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + systemInstruction: { + role: 'system', + parts: [{ text: 'system prompt' }], + }, + cachedContent: 'cached/abc', + generationConfig: { + responseModalities: ['TEXT'], + responseMimeType: 'application/json', + responseSchema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + }, + thinkingConfig: { + thinkingBudget: 512, + }, + imageConfig: { + aspectRatio: '1:1', + }, + }, + tools: [ + { + functionDeclarations: [ + { + name: 'lookup', + description: 'find facts', + }, + ], + }, + { googleSearch: {} }, + { urlContext: {} }, + { codeExecution: {} }, + ], + }); + }); +}); + +describe('geminiGenerateContentTransformer.aggregator', () => { + it('collects grounding metadata, citations, thought signatures, and usage from streamed chunks', () => { + const state = geminiGenerateContentTransformer.aggregator.createState(); + + geminiGenerateContentTransformer.aggregator.apply(state, [ + { + candidates: [ + { + groundingMetadata: { webSearchQueries: ['cat'] }, + citationMetadata: { citations: [{ uri: 'https://example.com' }] }, + content: { + parts: [ + { text: 'hello', thoughtSignature: 'sig-1' }, + ], + }, + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + cachedContentTokenCount: 3, + thoughtsTokenCount: 2, + }, + }, + ]); + + expect(state.groundingMetadata).toEqual([{ webSearchQueries: ['cat'] }]); + expect(state.citations).toEqual([{ citations: [{ uri: 'https://example.com' }] }]); + expect(state.thoughtSignatures).toEqual(['sig-1']); + expect(state.usage).toEqual({ + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + cachedContentTokenCount: 3, + thoughtsTokenCount: 2, + }); + }); + + it('serializes aggregate state back into Gemini response semantics', () => { + const state = geminiGenerateContentTransformer.aggregator.createState(); + geminiGenerateContentTransformer.aggregator.apply(state, { + responseId: 'resp-1', + modelVersion: 'gemini-2.5-pro', + candidates: [ + { + groundingMetadata: { webSearchQueries: ['cat'] }, + citationMetadata: { citations: [{ uri: 'https://example.com' }] }, + content: { + parts: [ + { text: 'thinking', thought: true, thoughtSignature: 'sig-1' }, + { text: 'answer' }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + cachedContentTokenCount: 3, + thoughtsTokenCount: 2, + }, + }); + + const response = serializeGeminiAggregateResponse(state); + expect(response).toEqual({ + responseId: 'resp-1', + modelVersion: 'gemini-2.5-pro', + candidates: [ + { + index: 0, + finishReason: 'STOP', + content: { + role: 'model', + parts: [ + { text: 'thinking', thought: true, thoughtSignature: 'sig-1' }, + { text: 'answer' }, + ], + }, + groundingMetadata: { webSearchQueries: ['cat'] }, + citationMetadata: { citations: [{ uri: 'https://example.com' }] }, + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + cachedContentTokenCount: 3, + thoughtsTokenCount: 2, + }, + }); + + expect(extractResponseMetadata(state)).toEqual({ + citations: [{ citations: [{ uri: 'https://example.com' }] }], + groundingMetadata: [{ webSearchQueries: ['cat'] }], + thoughtSignature: 'sig-1', + thoughtSignatures: ['sig-1'], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + cachedContentTokenCount: 3, + thoughtsTokenCount: 2, + }, + }); + }); + + it('preserves multiple candidate streams instead of collapsing them into one candidate', () => { + const state = geminiGenerateContentTransformer.aggregator.createState(); + geminiGenerateContentTransformer.aggregator.apply(state, { + responseId: 'resp-multi', + modelVersion: 'gemini-2.5-pro', + candidates: [ + { + index: 0, + content: { + parts: [{ text: 'candidate-a' }], + }, + finishReason: 'STOP', + groundingMetadata: { webSearchQueries: ['a'] }, + citationMetadata: { citations: [{ uri: 'https://a.example.com' }] }, + }, + { + index: 1, + content: { + parts: [{ text: 'candidate-b' }], + }, + finishReason: 'MAX_TOKENS', + groundingMetadata: { webSearchQueries: ['b'] }, + citationMetadata: { citations: [{ uri: 'https://b.example.com' }] }, + }, + ], + }); + + expect(serializeGeminiAggregateResponse(state)).toEqual({ + responseId: 'resp-multi', + modelVersion: 'gemini-2.5-pro', + candidates: [ + { + index: 0, + finishReason: 'STOP', + content: { + role: 'model', + parts: [{ text: 'candidate-a' }], + }, + groundingMetadata: { webSearchQueries: ['a'] }, + citationMetadata: { citations: [{ uri: 'https://a.example.com' }] }, + }, + { + index: 1, + finishReason: 'MAX_TOKENS', + content: { + role: 'model', + parts: [{ text: 'candidate-b' }], + }, + groundingMetadata: { webSearchQueries: ['b'] }, + citationMetadata: { citations: [{ uri: 'https://b.example.com' }] }, + }, + ], + }); + }); + + it('merges preserved request semantics into extracted metadata when provided', () => { + const metadata = extractResponseMetadata( + { + candidates: [ + { + groundingMetadata: { webSearchQueries: ['cat'] }, + citationMetadata: { citations: [{ uri: 'https://example.com' }] }, + content: { + parts: [{ text: 'answer', thoughtSignature: 'sig-1' }], + }, + }, + ], + }, + { + systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] }, + cachedContent: 'cached/abc', + generationConfig: { + responseModalities: ['TEXT'], + responseSchema: { type: 'object' }, + responseMimeType: 'application/json', + }, + tools: [ + { googleSearch: {} }, + { urlContext: {} }, + { codeExecution: {} }, + ], + }, + ); + + expect(metadata.systemInstruction).toEqual({ role: 'system', parts: [{ text: 'system prompt' }] }); + expect(metadata.cachedContent).toBe('cached/abc'); + expect(metadata.responseModalities).toEqual(['TEXT']); + expect(metadata.responseSchema).toEqual({ type: 'object' }); + expect(metadata.responseMimeType).toBe('application/json'); + expect(metadata.tools).toEqual([ + { googleSearch: {} }, + { urlContext: {} }, + { codeExecution: {} }, + ]); + }); +}); + +describe('extractGeminiUsage', () => { + it('maps cached and thought token counts into normalized usage', () => { + expect(extractGeminiUsage({ + usageMetadata: { + promptTokenCount: 11, + candidatesTokenCount: 7, + totalTokenCount: 18, + cachedContentTokenCount: 5, + thoughtsTokenCount: 3, + }, + })).toEqual({ + promptTokens: 11, + completionTokens: 10, + totalTokens: 18, + cachedTokens: 5, + reasoningTokens: 3, + }); + }); +}); + +describe('Gemini reasoning mapping', () => { + it('supports explicit none reasoning effort and maps it back from thinking config', () => { + expect(reasoningEffortToGeminiThinkingConfig('gemini-3-5m', 'none')).toEqual({ + thinkingLevel: 'none', + }); + + expect(geminiThinkingConfigToReasoning({ + thinkingLevel: 'none', + })).toEqual({ + reasoningEffort: 'none', + reasoningBudget: 0, + }); + }); + + it('uses thinkingLevel for Gemini 3 models when reasoning effort is provided', () => { + expect(reasoningEffortToGeminiThinkingConfig('gemini-3-5m', 'high')).toEqual({ + thinkingLevel: 'high', + }); + }); + + it('uses thinkingBudget for non-Gemini 3 models when reasoning effort is provided', () => { + expect(reasoningEffortToGeminiThinkingConfig('gemini-2.5-flash', 'medium')).toEqual({ + thinkingBudget: 8192, + }); + }); + + it('maps Gemini thinking config back to normalized reasoning hints', () => { + expect(geminiThinkingConfigToReasoning({ + thinkingLevel: 'medium', + })).toEqual({ + reasoningEffort: 'medium', + reasoningBudget: 8192, + }); + + expect(geminiThinkingConfigToReasoning({ + thinkingBudget: 512, + })).toEqual({ + reasoningEffort: 'low', + reasoningBudget: 512, + }); + }); +}); + +describe('geminiGenerateContentTransformer.stream', () => { + it('aggregates SSE payloads and JSON-array payloads to the same final semantics', () => { + const chunks = [ + { + responseId: 'resp-1', + modelVersion: 'gemini-2.5-pro', + candidates: [ + { + groundingMetadata: { webSearchQueries: ['cat'] }, + citationMetadata: { citations: [{ uri: 'https://example.com' }] }, + content: { + parts: [ + { text: 'thinking', thought: true, thoughtSignature: 'sig-1' }, + ], + }, + finishReason: '', + }, + ], + }, + { + responseId: 'resp-1', + modelVersion: 'gemini-2.5-pro', + candidates: [ + { + groundingMetadata: { webSearchQueries: ['cat'] }, + citationMetadata: { citations: [{ uri: 'https://example.com' }] }, + content: { + parts: [ + { text: 'answer' }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + cachedContentTokenCount: 3, + thoughtsTokenCount: 2, + }, + }, + ]; + + const ssePayload = chunks + .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`) + .join(''); + + const sseParsed = geminiGenerateContentTransformer.stream.parseSsePayloads(ssePayload); + const sseState = geminiGenerateContentTransformer.aggregator.createState(); + for (const payload of sseParsed.events) { + geminiGenerateContentTransformer.aggregator.apply(sseState, payload); + } + + const jsonState = geminiGenerateContentTransformer.aggregator.createState(); + for (const payload of geminiGenerateContentTransformer.stream.parseJsonArrayPayload(chunks)) { + geminiGenerateContentTransformer.aggregator.apply(jsonState, payload); + } + + expect(serializeGeminiAggregateResponse(sseState)).toEqual( + serializeGeminiAggregateResponse(jsonState), + ); + expect(extractResponseMetadata(sseState)).toEqual( + extractResponseMetadata(jsonState), + ); + expect(extractGeminiUsage(sseState)).toEqual( + extractGeminiUsage(jsonState), + ); + }); +}); diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts new file mode 100644 index 0000000..e614eba --- /dev/null +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -0,0 +1,75 @@ +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function baseIncludesVersion(baseUrl: string): boolean { + return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); +} + +export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { + const normalized = normalizeBaseUrl(baseUrl); + if (baseIncludesVersion(normalized)) return normalized; + return `${normalized}/${apiVersion}`; +} + +export function resolveGeminiModelsUrl( + baseUrl: string, + apiVersion: string, + apiKey: string, +): string { + const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); + const separator = base.includes('?') ? '&' : '?'; + return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`; +} + +export function resolveGeminiGenerateContentUrl( + baseUrl: string, + apiVersion: string, + modelActionPath: string, + apiKey: string, + search: string, +): string { + const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + params.set('key', apiKey); + const query = params.toString(); + return `${base}/${normalizedAction}${query ? `?${query}` : ''}`; +} + +import { geminiGenerateContentInbound } from './inbound.js'; +import { geminiGenerateContentOutbound } from './outbound.js'; +import { geminiGenerateContentStream } from './stream.js'; +import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js'; +import { geminiGenerateContentUsage } from './usage.js'; +import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js'; + +export const geminiGenerateContentTransformer = { + protocol: 'gemini/generate-content' as const, + inbound: geminiGenerateContentInbound, + outbound: geminiGenerateContentOutbound, + stream: geminiGenerateContentStream, + aggregator: { + createState: createGeminiGenerateContentAggregateState, + apply: applyGeminiGenerateContentAggregate, + }, + usage: geminiGenerateContentUsage, + convert: { + reasoningEffortToGeminiThinkingConfig, + geminiThinkingConfigToReasoning, + }, + resolveBaseUrl: resolveGeminiNativeBaseUrl, + resolveModelsUrl: resolveGeminiModelsUrl, + resolveActionUrl: resolveGeminiGenerateContentUrl, +}; + +export { + geminiGenerateContentInbound, + geminiGenerateContentOutbound, + geminiGenerateContentStream, + createGeminiGenerateContentAggregateState, + applyGeminiGenerateContentAggregate, + geminiGenerateContentUsage, + reasoningEffortToGeminiThinkingConfig, + geminiThinkingConfigToReasoning, +}; diff --git a/src/server/transformers/gemini/generate-content/outbound.ts b/src/server/transformers/gemini/generate-content/outbound.ts new file mode 100644 index 0000000..168222d --- /dev/null +++ b/src/server/transformers/gemini/generate-content/outbound.ts @@ -0,0 +1,190 @@ +import { + applyGeminiGenerateContentAggregate, + createGeminiGenerateContentAggregateState, + type GeminiGenerateContentAggregateState, +} from './aggregator.js'; + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function baseIncludesVersion(baseUrl: string): boolean { + return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); +} + +function resolveBaseUrl(baseUrl: string, apiVersion: string): string { + const normalized = normalizeBaseUrl(baseUrl); + if (baseIncludesVersion(normalized)) return normalized; + return `${normalized}/${apiVersion}`; +} + +function resolveModelsUrl( + baseUrl: string, + apiVersion: string, + apiKey: string, +): string { + const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion); + const separator = resolvedBaseUrl.includes('?') ? '&' : '?'; + return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`; +} + +function resolveActionUrl( + baseUrl: string, + apiVersion: string, + modelActionPath: string, + apiKey: string, + search: string, +): string { + const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + params.set('key', apiKey); + const query = params.toString(); + return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`; +} + +type GeminiRecord = Record; + +function isRecord(value: unknown): value is GeminiRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState { + return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations); +} + +function ensureAggregateState(payload: unknown): GeminiGenerateContentAggregateState { + if (isAggregateState(payload)) return payload; + + const state = createGeminiGenerateContentAggregateState(); + const chunks = Array.isArray(payload) ? payload : [payload]; + for (const chunk of chunks) { + applyGeminiGenerateContentAggregate(state, chunk); + } + return state; +} + +function buildUsageMetadata(state: GeminiGenerateContentAggregateState): GeminiRecord | undefined { + const usage = state.usage; + const next: GeminiRecord = {}; + if (typeof usage.promptTokenCount === 'number') next.promptTokenCount = usage.promptTokenCount; + if (typeof usage.candidatesTokenCount === 'number') next.candidatesTokenCount = usage.candidatesTokenCount; + if (typeof usage.totalTokenCount === 'number') next.totalTokenCount = usage.totalTokenCount; + if (typeof usage.cachedContentTokenCount === 'number') next.cachedContentTokenCount = usage.cachedContentTokenCount; + if (typeof usage.thoughtsTokenCount === 'number') next.thoughtsTokenCount = usage.thoughtsTokenCount; + return Object.keys(next).length > 0 ? next : undefined; +} + +function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiRecord[] { + if (state.candidates.length > 0) { + return state.candidates + .slice() + .sort((left, right) => left.index - right.index) + .map((candidate) => { + const next: GeminiRecord = { + index: candidate.index, + finishReason: candidate.finishReason || 'STOP', + content: { + role: 'model', + parts: candidate.parts, + }, + }; + if (candidate.groundingMetadata) next.groundingMetadata = candidate.groundingMetadata; + if (candidate.citationMetadata) next.citationMetadata = candidate.citationMetadata; + return next; + }); + } + + const fallback: GeminiRecord = { + index: 0, + finishReason: state.finishReason || 'STOP', + content: { + role: 'model', + parts: state.parts, + }, + }; + if (state.groundingMetadata.length > 0) { + fallback.groundingMetadata = state.groundingMetadata[0]; + } + if (state.citations.length > 0) { + fallback.citationMetadata = state.citations[0]; + } + return [fallback]; +} + +function extractRequestSemantics(requestPayload: unknown): GeminiRecord { + if (!isRecord(requestPayload)) return {}; + + const metadata: GeminiRecord = {}; + if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = requestPayload.systemInstruction; + if (requestPayload.cachedContent !== undefined) metadata.cachedContent = requestPayload.cachedContent; + + const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null; + if (generationConfig) { + if (generationConfig.responseModalities !== undefined) metadata.responseModalities = generationConfig.responseModalities; + if (generationConfig.responseSchema !== undefined) metadata.responseSchema = generationConfig.responseSchema; + if (generationConfig.responseMimeType !== undefined) metadata.responseMimeType = generationConfig.responseMimeType; + } + + if (Array.isArray(requestPayload.tools)) { + const requestTools = requestPayload.tools + .filter((item) => isRecord(item)) + .map((item) => { + const next: GeminiRecord = {}; + if (item.googleSearch !== undefined) next.googleSearch = item.googleSearch; + if (item.urlContext !== undefined) next.urlContext = item.urlContext; + if (item.codeExecution !== undefined) next.codeExecution = item.codeExecution; + if (item.functionDeclarations !== undefined) next.functionDeclarations = item.functionDeclarations; + return next; + }) + .filter((item) => Object.keys(item).length > 0); + + if (requestTools.length > 0) { + metadata.tools = requestTools; + } + } + + return metadata; +} + +export function serializeGeminiAggregateResponse( + payload: GeminiGenerateContentAggregateState | unknown, +): GeminiRecord { + const state = ensureAggregateState(payload); + const response: GeminiRecord = { + responseId: state.responseId || '', + modelVersion: state.modelVersion || '', + candidates: buildCandidates(state), + }; + + const usageMetadata = buildUsageMetadata(state); + if (usageMetadata) { + response.usageMetadata = usageMetadata; + } + + return response; +} + +export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord { + const state = ensureAggregateState(payload); + const metadata: GeminiRecord = extractRequestSemantics(requestPayload); + + if (state.citations.length > 0) metadata.citations = state.citations; + if (state.groundingMetadata.length > 0) metadata.groundingMetadata = state.groundingMetadata; + if (state.thoughtSignatures.length > 0) { + metadata.thoughtSignature = state.thoughtSignatures[0]; + metadata.thoughtSignatures = state.thoughtSignatures; + } + const usageMetadata = buildUsageMetadata(state); + if (usageMetadata) metadata.usageMetadata = usageMetadata; + + return metadata; +} + +export const geminiGenerateContentOutbound = { + resolveBaseUrl, + resolveModelsUrl, + resolveActionUrl, + extractResponseMetadata, + serializeAggregateResponse: serializeGeminiAggregateResponse, +}; diff --git a/src/server/transformers/gemini/generate-content/stream.ts b/src/server/transformers/gemini/generate-content/stream.ts new file mode 100644 index 0000000..03f7185 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/stream.ts @@ -0,0 +1,67 @@ +import { + applyGeminiGenerateContentAggregate, + createGeminiGenerateContentAggregateState, + type GeminiGenerateContentAggregateState, +} from './aggregator.js'; + +type ParsedSsePayloads = { + events: unknown[]; + rest: string; +}; + +function serializeSsePayload(payload: unknown): string { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +function parseSsePayloads(buffer: string): ParsedSsePayloads { + const normalized = buffer.replace(/\r\n/g, '\n'); + const events: unknown[] = []; + let rest = normalized; + + while (true) { + const boundary = rest.indexOf('\n\n'); + if (boundary < 0) break; + + const block = rest.slice(0, boundary); + rest = rest.slice(boundary + 2); + if (!block.trim()) continue; + + const data = block + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n') + .trim(); + + if (!data || data === '[DONE]') continue; + + try { + events.push(JSON.parse(data)); + } catch { + // Ignore malformed event payloads so aggregation remains tolerant. + } + } + + return { events, rest }; +} + +export const geminiGenerateContentStream = { + isSseContentType(contentType: string | null | undefined): boolean { + return (contentType || '').toLowerCase().includes('text/event-stream'); + }, + + parseJsonArrayPayload(payload: unknown): unknown[] { + return Array.isArray(payload) ? payload : []; + }, + + parseSsePayloads, + serializeSsePayload, + + createAggregateState(): GeminiGenerateContentAggregateState { + return createGeminiGenerateContentAggregateState(); + }, + + applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState { + return applyGeminiGenerateContentAggregate(state, payload); + }, +}; diff --git a/src/server/transformers/gemini/generate-content/usage.ts b/src/server/transformers/gemini/generate-content/usage.ts new file mode 100644 index 0000000..d02138b --- /dev/null +++ b/src/server/transformers/gemini/generate-content/usage.ts @@ -0,0 +1,54 @@ +import { createEmptyNormalizedUsage, mergeNormalizedUsage, type NormalizedUsage } from '../../shared/normalized.js'; +import { type GeminiGenerateContentAggregateState } from './aggregator.js'; + +type GeminiRecord = Record; + +function isRecord(value: unknown): value is GeminiRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState { + return isRecord(value) && Array.isArray(value.parts) && isRecord(value.usage); +} + +export function extractGeminiUsage(payload: unknown): NormalizedUsage { + const usageMetadata = isAggregateState(payload) + ? payload.usage + : (isRecord(payload) && isRecord(payload.usageMetadata) ? payload.usageMetadata : null); + + if (!usageMetadata) { + return createEmptyNormalizedUsage(); + } + + const promptTokens = typeof usageMetadata.promptTokenCount === 'number' + ? usageMetadata.promptTokenCount + : 0; + const candidatesTokenCount = typeof usageMetadata.candidatesTokenCount === 'number' + ? usageMetadata.candidatesTokenCount + : 0; + const thoughtsTokenCount = typeof usageMetadata.thoughtsTokenCount === 'number' + ? usageMetadata.thoughtsTokenCount + : 0; + const completionTokens = candidatesTokenCount + thoughtsTokenCount; + const totalTokens = typeof usageMetadata.totalTokenCount === 'number' + ? usageMetadata.totalTokenCount + : promptTokens + completionTokens; + const cachedTokens = typeof usageMetadata.cachedContentTokenCount === 'number' + ? usageMetadata.cachedContentTokenCount + : 0; + const reasoningTokens = thoughtsTokenCount; + + return { + promptTokens, + completionTokens, + totalTokens, + cachedTokens, + reasoningTokens, + }; +} + +export const geminiGenerateContentUsage = { + empty: createEmptyNormalizedUsage, + fromPayload: extractGeminiUsage, + merge: mergeNormalizedUsage, +}; diff --git a/src/server/transformers/openai/chat/aggregator.ts b/src/server/transformers/openai/chat/aggregator.ts new file mode 100644 index 0000000..25d0a90 --- /dev/null +++ b/src/server/transformers/openai/chat/aggregator.ts @@ -0,0 +1,121 @@ +import { type NormalizedFinalResponse, type NormalizedStreamEvent } from '../../shared/normalized.js'; +import type { + OpenAiChatNormalizedFinalResponse, + OpenAiChatNormalizedStreamEvent, + OpenAiChatUsageDetails, +} from './model.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function pushUniqueAnnotation( + annotations: Array>, + seenUrls: Set, + candidate: unknown, +) { + if (!isRecord(candidate)) return; + const citation = isRecord(candidate.url_citation) ? candidate.url_citation : null; + const url = citation && typeof citation.url === 'string' ? citation.url.trim() : ''; + if (!url || seenUrls.has(url)) return; + seenUrls.add(url); + annotations.push(candidate); +} + +function mergeUsageDetails( + target: OpenAiChatUsageDetails | undefined, + next: OpenAiChatUsageDetails | undefined, +): OpenAiChatUsageDetails | undefined { + if (!target && !next) return undefined; + return { + ...(target || {}), + ...(next || {}), + }; +} + +export type OpenAiChatAggregateState = { + content: string[]; + reasoning: string[]; + toolCalls: Array<{ id: string; name: string; arguments: string }>; + finishReason: string | null; + annotations: Array>; + annotationUrls: Set; + citations: Set; + usageDetails?: OpenAiChatUsageDetails; +}; + +export function createOpenAiChatAggregateState(): OpenAiChatAggregateState { + return { + content: [], + reasoning: [], + toolCalls: [], + finishReason: null, + annotations: [], + annotationUrls: new Set(), + citations: new Set(), + usageDetails: undefined, + }; +} + +export function applyOpenAiChatStreamEvent( + state: OpenAiChatAggregateState, + event: OpenAiChatNormalizedStreamEvent, +): OpenAiChatAggregateState { + if (event.contentDelta) state.content.push(event.contentDelta); + if (event.reasoningDelta) state.reasoning.push(event.reasoningDelta); + if (event.finishReason !== undefined) state.finishReason = event.finishReason ?? null; + if (Array.isArray(event.toolCallDeltas)) { + for (const delta of event.toolCallDeltas) { + if (!delta.id && !delta.name && !delta.argumentsDelta) continue; + const index = Number.isFinite(delta.index) ? Math.max(0, Math.trunc(delta.index)) : state.toolCalls.length; + while (state.toolCalls.length <= index) { + state.toolCalls.push({ id: '', name: '', arguments: '' }); + } + const existing = state.toolCalls[index]; + if (delta.id) existing.id = delta.id; + if (delta.name) existing.name = delta.name; + if (delta.argumentsDelta) existing.arguments += delta.argumentsDelta; + } + } + if (Array.isArray(event.annotations)) { + for (const annotation of event.annotations) { + pushUniqueAnnotation(state.annotations, state.annotationUrls, annotation); + } + } + if (Array.isArray(event.citations)) { + for (const citation of event.citations) { + if (typeof citation === 'string' && citation.trim()) { + state.citations.add(citation.trim()); + } + } + } + state.usageDetails = mergeUsageDetails(state.usageDetails, event.usageDetails); + return state; +} + +export function finalizeOpenAiChatAggregate( + state: OpenAiChatAggregateState, + normalized: OpenAiChatNormalizedFinalResponse, +): OpenAiChatNormalizedFinalResponse { + const mergedToolCalls = state.toolCalls.filter((item) => item.id || item.name || item.arguments); + if ( + state.content.length === 0 + && state.reasoning.length === 0 + && mergedToolCalls.length === 0 + && state.annotations.length === 0 + && state.citations.size === 0 + && !state.usageDetails + ) { + return normalized; + } + return { + ...normalized, + content: state.content.join('') || normalized.content, + reasoningContent: state.reasoning.join('') || normalized.reasoningContent, + finishReason: state.finishReason ?? normalized.finishReason, + toolCalls: mergedToolCalls.length > 0 ? mergedToolCalls : normalized.toolCalls, + annotations: state.annotations.length > 0 ? state.annotations : normalized.annotations, + citations: state.citations.size > 0 ? Array.from(state.citations).sort() : normalized.citations, + usageDetails: mergeUsageDetails(normalized.usageDetails, state.usageDetails), + }; +} diff --git a/src/server/transformers/openai/chat/helpers.ts b/src/server/transformers/openai/chat/helpers.ts new file mode 100644 index 0000000..d932c57 --- /dev/null +++ b/src/server/transformers/openai/chat/helpers.ts @@ -0,0 +1,238 @@ +import type { + OpenAiChatNormalizedFinalResponse, + OpenAiChatNormalizedStreamEvent, + OpenAiChatRequestMetadata, + OpenAiChatUsageDetails, +} from './model.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (typeof item === 'string') return item.trim(); + if (isRecord(item)) { + const direct = typeof item.url === 'string' + ? item.url + : typeof item.uri === 'string' + ? item.uri + : ''; + return direct.trim(); + } + return ''; + }) + .filter((item) => item.length > 0); +} + +function toNumericRecord(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const entries = Object.entries(value) + .map(([key, raw]) => { + const numeric = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(numeric)) return null; + return [key, numeric] as const; + }) + .filter((entry): entry is readonly [string, number] => !!entry); + if (entries.length <= 0) return undefined; + return Object.fromEntries(entries); +} + +function stableKey(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableKey(item)).join(',')}]`; + } + if (isRecord(value)) { + const entries = Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => `${JSON.stringify(key)}:${stableKey(nested)}`); + return `{${entries.join(',')}}`; + } + return JSON.stringify(value); +} + +function annotationUrl(annotation: Record): string { + const urlCitation = asRecord(annotation.url_citation) ?? asRecord(annotation.urlCitation); + const direct = typeof annotation.url === 'string' ? annotation.url : ''; + const nested = urlCitation && typeof urlCitation.url === 'string' ? urlCitation.url : ''; + return (direct || nested).trim(); +} + +export function dedupeAnnotations(input: unknown): Array> { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const result: Array> = []; + + for (const item of input) { + if (!isRecord(item)) continue; + const key = annotationUrl(item) || stableKey(item); + if (!key || seen.has(key)) continue; + seen.add(key); + result.push(item); + } + + return result; +} + +export function dedupeCitations(input: unknown): string[] { + const citations = new Set(); + + for (const citation of toStringArray(input)) { + citations.add(citation); + } + + if (Array.isArray(input)) { + for (const item of input) { + if (!isRecord(item)) continue; + const nestedUrl = annotationUrl(item); + if (nestedUrl) citations.add(nestedUrl); + } + } + + return Array.from(citations); +} + +function extractAnnotationsFromPayload(payload: Record): Array> { + const collected: Array> = []; + const append = (value: unknown) => { + for (const item of dedupeAnnotations(value)) { + collected.push(item); + } + }; + + append(payload.annotations); + + const choices = Array.isArray(payload.choices) ? payload.choices : []; + for (const choice of choices) { + const choiceRecord = asRecord(choice); + if (!choiceRecord) continue; + append(asRecord(choiceRecord.message)?.annotations); + append(asRecord(choiceRecord.delta)?.annotations); + } + + return dedupeAnnotations(collected); +} + +function extractCitationsFromPayload( + payload: Record, + annotations: Array>, +): string[] { + const citations = new Set(); + + for (const citation of dedupeCitations(payload.citations)) { + citations.add(citation); + } + + const transformerMetadata = asRecord(payload.transformer_metadata) ?? asRecord(payload.transformerMetadata); + if (transformerMetadata) { + for (const citation of dedupeCitations(transformerMetadata.citations)) { + citations.add(citation); + } + } + + for (const annotation of annotations) { + const url = annotationUrl(annotation); + if (url) citations.add(url); + } + + return Array.from(citations); +} + +export function extractChatUsageDetails(payload: Record): OpenAiChatUsageDetails | undefined { + const usage = asRecord(payload.usage); + if (!usage) return undefined; + + const promptDetails = toNumericRecord(usage.prompt_tokens_details ?? usage.promptTokensDetails); + const completionDetails = toNumericRecord(usage.completion_tokens_details ?? usage.completionTokensDetails); + if (!promptDetails && !completionDetails) return undefined; + + return { + ...(promptDetails ? { prompt_tokens_details: promptDetails } : {}), + ...(completionDetails ? { completion_tokens_details: completionDetails } : {}), + }; +} + +export function mergeChatUsageDetails( + base: OpenAiChatUsageDetails | undefined, + next: OpenAiChatUsageDetails | undefined, +): OpenAiChatUsageDetails | undefined { + if (!base && !next) return undefined; + + const mergeNumericMaps = ( + left: Record | undefined, + right: Record | undefined, + ): Record | undefined => { + if (!left && !right) return undefined; + return Object.fromEntries( + Array.from(new Set([...Object.keys(left || {}), ...Object.keys(right || {})])) + .map((key) => [key, Math.max(left?.[key] || 0, right?.[key] || 0)]), + ); + }; + + return { + ...(mergeNumericMaps(base?.prompt_tokens_details, next?.prompt_tokens_details) + ? { prompt_tokens_details: mergeNumericMaps(base?.prompt_tokens_details, next?.prompt_tokens_details)! } + : {}), + ...(mergeNumericMaps(base?.completion_tokens_details, next?.completion_tokens_details) + ? { completion_tokens_details: mergeNumericMaps(base?.completion_tokens_details, next?.completion_tokens_details)! } + : {}), + }; +} + +export function extractUsagePayload(payload: Record): Record | undefined { + const usage = asRecord(payload.usage); + if (!usage) return undefined; + return usage; +} + +export function extractChatResponseExtras(payload: unknown): Pick< + OpenAiChatNormalizedFinalResponse, + 'annotations' | 'citations' | 'usageDetails' | 'usagePayload' +> & Pick { + const record = asRecord(payload); + if (!record) return {}; + + const annotations = extractAnnotationsFromPayload(record); + const citations = extractCitationsFromPayload(record, annotations); + const usageDetails = extractChatUsageDetails(record); + const usagePayload = extractUsagePayload(record); + + return { + ...(annotations.length > 0 ? { annotations } : {}), + ...(citations.length > 0 ? { citations } : {}), + ...(usageDetails ? { usageDetails } : {}), + ...(usagePayload ? { usagePayload } : {}), + }; +} + +export function extractChatRequestMetadata(body: unknown): OpenAiChatRequestMetadata | undefined { + const raw = asRecord(body); + if (!raw) return undefined; + + const streamOptions = asRecord(raw.stream_options) ?? asRecord(raw.streamOptions); + const metadata: OpenAiChatRequestMetadata = { + ...(raw.modalities !== undefined ? { modalities: raw.modalities } : {}), + ...(raw.audio !== undefined ? { audio: raw.audio } : {}), + ...(raw.reasoning_effort !== undefined ? { reasoningEffort: raw.reasoning_effort } : {}), + ...(raw.reasoning_budget !== undefined ? { reasoningBudget: raw.reasoning_budget } : {}), + ...(raw.reasoning_summary !== undefined ? { reasoningSummary: raw.reasoning_summary } : {}), + ...(raw.service_tier !== undefined ? { serviceTier: raw.service_tier } : {}), + ...(raw.top_logprobs !== undefined ? { topLogprobs: raw.top_logprobs } : {}), + ...(raw.logit_bias !== undefined ? { logitBias: raw.logit_bias } : {}), + ...(raw.prompt_cache_key !== undefined ? { promptCacheKey: raw.prompt_cache_key } : {}), + ...(raw.safety_identifier !== undefined ? { safetyIdentifier: raw.safety_identifier } : {}), + ...(raw.verbosity !== undefined ? { verbosity: raw.verbosity } : {}), + ...(raw.response_format !== undefined ? { responseFormat: raw.response_format } : {}), + ...(streamOptions && streamOptions.include_usage !== undefined + ? { streamOptionsIncludeUsage: Boolean(streamOptions.include_usage) } + : {}), + }; + + return Object.keys(metadata).length > 0 ? metadata : undefined; +} diff --git a/src/server/transformers/openai/chat/inbound.ts b/src/server/transformers/openai/chat/inbound.ts new file mode 100644 index 0000000..3d1091e --- /dev/null +++ b/src/server/transformers/openai/chat/inbound.ts @@ -0,0 +1,19 @@ +import { parseDownstreamChatRequest } from '../../shared/normalized.js'; +import { extractChatRequestMetadata } from './helpers.js'; +import type { OpenAiChatParsedRequest } from './model.js'; + +export const openAiChatInbound = { + parse(body: unknown): { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown } } { + const parsed = parseDownstreamChatRequest(body, 'openai') as { + value?: OpenAiChatParsedRequest; + error?: { statusCode: number; payload: unknown }; + }; + if (!parsed.value) return parsed; + + parsed.value = { + ...parsed.value, + requestMetadata: extractChatRequestMetadata(body), + }; + return parsed; + }, +}; diff --git a/src/server/transformers/openai/chat/index.test.ts b/src/server/transformers/openai/chat/index.test.ts new file mode 100644 index 0000000..2ceba9c --- /dev/null +++ b/src/server/transformers/openai/chat/index.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest'; + +import { createClaudeDownstreamContext } from '../../shared/normalized.js'; +import { openAiChatTransformer } from './index.js'; + +function parseSsePayloads(lines: string[]): Array> { + return lines + .filter((line) => line.startsWith('data: ') && line.trim() !== 'data: [DONE]') + .map((line) => JSON.parse(line.slice(6)) as Record); +} + +describe('openAiChatTransformer.inbound', () => { + it('captures chat request metadata fields without changing upstream body', () => { + const result = openAiChatTransformer.transformRequest({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + modalities: ['text', 'audio'], + audio: { voice: 'alloy', format: 'mp3' }, + reasoning_effort: 'high', + reasoning_budget: 1024, + reasoning_summary: 'detailed', + service_tier: 'priority', + top_logprobs: 3, + logit_bias: { '42': 5 }, + prompt_cache_key: 'cache-key', + safety_identifier: 'safety-id', + verbosity: 'low', + response_format: { type: 'json_object' }, + stream_options: { include_usage: true }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value?.upstreamBody).toMatchObject({ + modalities: ['text', 'audio'], + audio: { voice: 'alloy', format: 'mp3' }, + reasoning_effort: 'high', + reasoning_budget: 1024, + reasoning_summary: 'detailed', + service_tier: 'priority', + top_logprobs: 3, + logit_bias: { '42': 5 }, + prompt_cache_key: 'cache-key', + safety_identifier: 'safety-id', + verbosity: 'low', + response_format: { type: 'json_object' }, + stream_options: { include_usage: true }, + }); + expect((result.value as any)?.requestMetadata).toEqual({ + modalities: ['text', 'audio'], + audio: { voice: 'alloy', format: 'mp3' }, + reasoningEffort: 'high', + reasoningBudget: 1024, + reasoningSummary: 'detailed', + serviceTier: 'priority', + topLogprobs: 3, + logitBias: { '42': 5 }, + promptCacheKey: 'cache-key', + safetyIdentifier: 'safety-id', + verbosity: 'low', + responseFormat: { type: 'json_object' }, + streamOptionsIncludeUsage: true, + }); + }); +}); + +describe('openAiChatTransformer.outbound', () => { + it('carries annotations, citations, and detailed usage through final serialization', () => { + const normalized = openAiChatTransformer.transformFinalResponse({ + id: 'chatcmpl-1', + model: 'gpt-5', + created: 123, + choices: [{ + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: 'hello', + reasoning_content: 'think', + annotations: [ + { type: 'url_citation', url_citation: { url: 'https://a.example' } }, + { type: 'url_citation', url_citation: { url: 'https://a.example' } }, + ], + }, + }], + citations: ['https://c.example', 'https://c.example'], + usage: { + prompt_tokens: 11, + completion_tokens: 7, + total_tokens: 18, + prompt_tokens_details: { cached_tokens: 3 }, + completion_tokens_details: { reasoning_tokens: 2 }, + }, + }, 'gpt-5'); + + const payload = openAiChatTransformer.serializeFinalResponse(normalized, { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }); + + const choice = (payload as any).choices[0]; + expect(choice.message.annotations).toEqual([ + { type: 'url_citation', url_citation: { url: 'https://a.example' } }, + ]); + expect((payload as any).citations).toEqual(['https://c.example', 'https://a.example']); + expect((payload as any).usage).toMatchObject({ + prompt_tokens: 11, + completion_tokens: 7, + total_tokens: 18, + prompt_tokens_details: { cached_tokens: 3 }, + completion_tokens_details: { reasoning_tokens: 2 }, + }); + }); +}); + +describe('openAiChatTransformer.stream', () => { + it('preserves annotations, citations, and usage payload on serialized stream chunks', () => { + const context = openAiChatTransformer.createStreamContext('gpt-5'); + const event = openAiChatTransformer.transformStreamEvent({ + id: 'chatcmpl-1', + model: 'gpt-5', + choices: [{ + index: 0, + finish_reason: null, + delta: { + role: 'assistant', + content: 'hello', + reasoning_content: 'why', + annotations: [ + { type: 'url_citation', url_citation: { url: 'https://a.example' } }, + ], + }, + }], + citations: ['https://c.example', 'https://a.example'], + usage: { + prompt_tokens: 11, + completion_tokens: 7, + total_tokens: 18, + prompt_tokens_details: { cached_tokens: 3 }, + }, + }, context, 'gpt-5'); + + const payloads = parseSsePayloads( + openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()), + ); + + expect(payloads[0]).toMatchObject({ + citations: ['https://c.example', 'https://a.example'], + usage: { + prompt_tokens: 11, + completion_tokens: 7, + total_tokens: 18, + prompt_tokens_details: { cached_tokens: 3 }, + }, + }); + expect(((payloads[0] as any).choices[0] as any).delta.annotations).toEqual([ + { type: 'url_citation', url_citation: { url: 'https://a.example' } }, + ]); + }); +}); + +describe('openAiChatTransformer.aggregator', () => { + it('deduplicates annotations/citations while aggregating reasoning, tool calls, and usage details', () => { + const state = openAiChatTransformer.aggregator.createState(); + + openAiChatTransformer.aggregator.applyEvent(state, { + contentDelta: 'hel', + reasoningDelta: 'why', + toolCallDeltas: [{ + index: 0, + id: 'call_1', + name: 'search', + argumentsDelta: '{"q":"cat"', + }], + annotations: [ + { type: 'url_citation', url_citation: { url: 'https://a.example' } }, + ], + citations: ['https://c.example'], + usageDetails: { + prompt_tokens_details: { cached_tokens: 2 }, + }, + } as any); + + openAiChatTransformer.aggregator.applyEvent(state, { + contentDelta: 'lo', + reasoningDelta: ' now', + toolCallDeltas: [{ + index: 0, + id: 'call_1', + name: 'search', + argumentsDelta: ',"k":1}', + }], + annotations: [ + { type: 'url_citation', url_citation: { url: 'https://a.example' } }, + { type: 'url_citation', url_citation: { url: 'https://b.example' } }, + ], + citations: ['https://c.example', 'https://d.example'], + usageDetails: { + completion_tokens_details: { reasoning_tokens: 4 }, + }, + finishReason: 'tool_calls', + } as any); + + const normalized = openAiChatTransformer.aggregator.finalize(state, { + id: 'chatcmpl-1', + model: 'gpt-5', + created: 123, + content: '', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }); + + expect(normalized).toMatchObject({ + content: 'hello', + reasoningContent: 'why now', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_1', + name: 'search', + arguments: '{"q":"cat","k":1}', + }], + citations: ['https://c.example', 'https://d.example'], + usageDetails: { + prompt_tokens_details: { cached_tokens: 2 }, + completion_tokens_details: { reasoning_tokens: 4 }, + }, + }); + expect((normalized as any).annotations).toEqual([ + { type: 'url_citation', url_citation: { url: 'https://a.example' } }, + { type: 'url_citation', url_citation: { url: 'https://b.example' } }, + ]); + }); +}); diff --git a/src/server/transformers/openai/chat/index.ts b/src/server/transformers/openai/chat/index.ts new file mode 100644 index 0000000..ec45858 --- /dev/null +++ b/src/server/transformers/openai/chat/index.ts @@ -0,0 +1,59 @@ +import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext } from '../../shared/normalized.js'; +import { openAiChatInbound } from './inbound.js'; +import { openAiChatOutbound } from './outbound.js'; +import { openAiChatStream } from './stream.js'; +import { openAiChatUsage } from './usage.js'; +import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js'; + +export const openAiChatTransformer = { + protocol: 'openai/chat' as const, + inbound: openAiChatInbound, + outbound: openAiChatOutbound, + stream: openAiChatStream, + usage: openAiChatUsage, + aggregator: { + createState: createOpenAiChatAggregateState, + applyEvent: applyOpenAiChatStreamEvent, + finalize: finalizeOpenAiChatAggregate, + }, + transformRequest(body: unknown): ReturnType { + return openAiChatInbound.parse(body); + }, + createStreamContext(modelName: string): StreamTransformContext { + return openAiChatStream.createContext(modelName); + }, + transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse { + return openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText); + }, + transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent { + return openAiChatStream.normalizeEvent(payload, context, modelName); + }, + serializeStreamEvent( + event: NormalizedStreamEvent, + context: StreamTransformContext, + claudeContext: Parameters[2], + ): string[] { + return openAiChatStream.serializeEvent(event, context, claudeContext); + }, + serializeDone( + context: StreamTransformContext, + claudeContext: Parameters[1], + ): string[] { + return openAiChatStream.serializeDone(context, claudeContext); + }, + serializeFinalResponse( + normalized: NormalizedFinalResponse, + usage: Parameters[1], + ) { + return openAiChatOutbound.serializeFinal(normalized, usage); + }, + buildSyntheticChunks(normalized: NormalizedFinalResponse) { + return openAiChatOutbound.buildSyntheticChunks(normalized); + }, + pullSseEvents(buffer: string) { + return openAiChatStream.pullSseEvents(buffer); + }, +}; + +export type OpenAiChatTransformer = typeof openAiChatTransformer; +export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest; diff --git a/src/server/transformers/openai/chat/model.ts b/src/server/transformers/openai/chat/model.ts new file mode 100644 index 0000000..218fcb8 --- /dev/null +++ b/src/server/transformers/openai/chat/model.ts @@ -0,0 +1,44 @@ +import type { + NormalizedFinalResponse, + NormalizedStreamEvent, + ParsedDownstreamChatRequest, +} from '../../shared/normalized.js'; + +export type OpenAiChatRequestMetadata = { + modalities?: unknown; + audio?: unknown; + reasoningEffort?: unknown; + reasoningBudget?: unknown; + reasoningSummary?: unknown; + serviceTier?: unknown; + topLogprobs?: unknown; + logitBias?: unknown; + promptCacheKey?: unknown; + safetyIdentifier?: unknown; + verbosity?: unknown; + responseFormat?: unknown; + streamOptionsIncludeUsage?: boolean | null; +}; + +export type OpenAiChatUsageDetails = { + prompt_tokens_details?: Record; + completion_tokens_details?: Record; +}; + +export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & { + requestMetadata?: OpenAiChatRequestMetadata; +}; + +export type OpenAiChatNormalizedFinalResponse = NormalizedFinalResponse & { + annotations?: Array>; + citations?: string[]; + usageDetails?: OpenAiChatUsageDetails; + usagePayload?: Record; +}; + +export type OpenAiChatNormalizedStreamEvent = NormalizedStreamEvent & { + annotations?: Array>; + citations?: string[]; + usageDetails?: OpenAiChatUsageDetails; + usagePayload?: Record; +}; diff --git a/src/server/transformers/openai/chat/outbound.ts b/src/server/transformers/openai/chat/outbound.ts new file mode 100644 index 0000000..a3f9c44 --- /dev/null +++ b/src/server/transformers/openai/chat/outbound.ts @@ -0,0 +1,99 @@ +import { + buildSyntheticOpenAiChunks, + normalizeUpstreamFinalResponse, + serializeFinalResponse, + type NormalizedFinalResponse, +} from '../../shared/normalized.js'; +import { extractChatResponseExtras } from './helpers.js'; +import type { OpenAiChatNormalizedFinalResponse } from './model.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function mergeUsagePayload( + usage: unknown, + normalized: OpenAiChatNormalizedFinalResponse, +): Record | undefined { + const merged = isRecord(usage) ? { ...usage } : {}; + const usagePayload = normalized.usagePayload && isRecord(normalized.usagePayload) + ? normalized.usagePayload + : null; + + if (usagePayload) { + for (const [key, value] of Object.entries(usagePayload)) { + if (key === 'prompt_tokens' || key === 'completion_tokens' || key === 'total_tokens') continue; + if (merged[key] === undefined) merged[key] = value; + } + } + + if (normalized.usageDetails?.prompt_tokens_details) { + merged.prompt_tokens_details = normalized.usageDetails.prompt_tokens_details; + } + if (normalized.usageDetails?.completion_tokens_details) { + merged.completion_tokens_details = normalized.usageDetails.completion_tokens_details; + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} + +export const openAiChatOutbound = { + normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): OpenAiChatNormalizedFinalResponse { + return { + ...normalizeUpstreamFinalResponse(payload, modelName, fallbackText), + ...extractChatResponseExtras(payload), + }; + }, + serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) { + const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse; + const payload = serializeFinalResponse( + 'openai', + normalized, + usage as { promptTokens: number; completionTokens: number; totalTokens: number }, + ) as Record; + + if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) { + payload.citations = chatNormalized.citations; + } + + if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) { + const choices = Array.isArray(payload.choices) ? payload.choices : []; + const firstChoice = choices[0]; + if (isRecord(firstChoice) && isRecord(firstChoice.message)) { + firstChoice.message.annotations = chatNormalized.annotations; + } + } + + const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized); + if (mergedUsage) { + payload.usage = { + ...(isRecord(payload.usage) ? payload.usage : {}), + ...mergedUsage, + }; + } + + return payload; + }, + buildSyntheticChunks(normalized: NormalizedFinalResponse) { + const chunks = buildSyntheticOpenAiChunks(normalized); + const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse; + if (chunks.length <= 0) return chunks; + + if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) { + chunks[0] = { + ...chunks[0], + citations: chatNormalized.citations, + }; + } + + if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) { + const firstChunk = isRecord(chunks[0]) ? chunks[0] : null; + const firstChoice = firstChunk && Array.isArray(firstChunk.choices) ? firstChunk.choices[0] : null; + if (isRecord(firstChoice) && isRecord(firstChoice.delta)) { + firstChoice.delta.annotations = chatNormalized.annotations; + } + } + + return chunks; + }, +}; diff --git a/src/server/transformers/openai/chat/stream.ts b/src/server/transformers/openai/chat/stream.ts new file mode 100644 index 0000000..8418285 --- /dev/null +++ b/src/server/transformers/openai/chat/stream.ts @@ -0,0 +1,109 @@ +import { + createClaudeDownstreamContext, + createStreamTransformContext, + normalizeUpstreamStreamEvent, + pullSseEventsWithDone, + serializeNormalizedStreamEvent, + serializeStreamDone, + type ClaudeDownstreamContext, + type StreamTransformContext, +} from '../../shared/normalized.js'; +import { extractChatResponseExtras } from './helpers.js'; +import type { OpenAiChatNormalizedStreamEvent } from './model.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseSerializedSse(lines: string[]): Array<{ index: number; payload: Record }> { + return lines + .map((line, index) => { + if (!line.startsWith('data: ') || line.trim() === 'data: [DONE]') return null; + try { + return { + index, + payload: JSON.parse(line.slice(6)) as Record, + }; + } catch { + return null; + } + }) + .filter((item): item is { index: number; payload: Record } => !!item); +} + +export const openAiChatStream = { + createContext(modelName: string): StreamTransformContext { + return createStreamTransformContext(modelName); + }, + normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent { + return { + ...normalizeUpstreamStreamEvent(payload, context, modelName), + ...extractChatResponseExtras(payload), + }; + }, + serializeEvent( + event: OpenAiChatNormalizedStreamEvent, + context: StreamTransformContext, + downstreamContext?: ClaudeDownstreamContext, + ): string[] { + const lines = serializeNormalizedStreamEvent( + 'openai', + event, + context, + downstreamContext ?? createClaudeDownstreamContext(), + ); + + if ( + (!Array.isArray(event.annotations) || event.annotations.length <= 0) + && (!Array.isArray(event.citations) || event.citations.length <= 0) + && !event.usagePayload + && !event.usageDetails + ) { + return lines; + } + + const parsedEvents = parseSerializedSse(lines); + if (parsedEvents.length <= 0) return lines; + + for (const parsed of parsedEvents) { + const payload = parsed.payload; + if (Array.isArray(event.citations) && event.citations.length > 0) { + payload.citations = event.citations; + } + + const firstChoice = Array.isArray(payload.choices) ? payload.choices[0] : null; + if (Array.isArray(event.annotations) && event.annotations.length > 0 && isRecord(firstChoice) && isRecord(firstChoice.delta)) { + firstChoice.delta.annotations = event.annotations; + } + + if (event.usagePayload || event.usageDetails) { + payload.usage = { + ...(isRecord(event.usagePayload) ? event.usagePayload : {}), + ...(event.usageDetails?.prompt_tokens_details + ? { prompt_tokens_details: event.usageDetails.prompt_tokens_details } + : {}), + ...(event.usageDetails?.completion_tokens_details + ? { completion_tokens_details: event.usageDetails.completion_tokens_details } + : {}), + }; + } + + lines[parsed.index] = `data: ${JSON.stringify(payload)}\n\n`; + } + + return lines; + }, + serializeDone( + context: StreamTransformContext, + downstreamContext?: ClaudeDownstreamContext, + ): string[] { + return serializeStreamDone( + 'openai', + context, + downstreamContext ?? createClaudeDownstreamContext(), + ); + }, + pullSseEvents(buffer: string) { + return pullSseEventsWithDone(buffer); + }, +}; diff --git a/src/server/transformers/openai/chat/usage.ts b/src/server/transformers/openai/chat/usage.ts new file mode 100644 index 0000000..c1e953f --- /dev/null +++ b/src/server/transformers/openai/chat/usage.ts @@ -0,0 +1,6 @@ +import { createEmptyNormalizedUsage, mergeNormalizedUsage } from '../../shared/normalized.js'; + +export const openAiChatUsage = { + empty: createEmptyNormalizedUsage, + merge: mergeNormalizedUsage, +}; diff --git a/src/server/transformers/openai/responses/aggregator.test.ts b/src/server/transformers/openai/responses/aggregator.test.ts new file mode 100644 index 0000000..c7f20bf --- /dev/null +++ b/src/server/transformers/openai/responses/aggregator.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from 'vitest'; + +import { + createOpenAiResponsesAggregateState, + serializeConvertedResponsesEvents, + type OpenAiResponsesAggregateState, + completeResponsesStream, +} from './aggregator.js'; +import { openAiResponsesStream } from './stream.js'; + +function collectSerializedEvents(input: string[]): Array<{ event: string; data: any }> { + const events: Array<{ event: string; data: any }> = []; + for (const chunk of input) { + const blocks = chunk.split('\n\n').filter((item) => item.trim().length > 0); + for (const block of blocks) { + const lines = block.split('\n'); + const eventLine = lines.find((line) => line.startsWith('event: ')); + const dataLine = lines.find((line) => line.startsWith('data: ')); + if (!dataLine) continue; + const rawData = dataLine.slice('data: '.length); + if (rawData === '[DONE]') { + events.push({ event: eventLine?.slice('event: '.length) || 'done', data: '[DONE]' }); + continue; + } + events.push({ + event: eventLine?.slice('event: '.length) || '', + data: JSON.parse(rawData), + }); + } + } + return events; +} + +function feedResponsesEvent( + state: OpenAiResponsesAggregateState, + payload: Record, +): Array<{ event: string; data: any }> { + const context = openAiResponsesStream.createContext('gpt-5'); + const normalizedEvent = openAiResponsesStream.normalizeEvent(payload, context, 'gpt-5'); + return collectSerializedEvents( + serializeConvertedResponsesEvents({ + state, + streamContext: context, + event: normalizedEvent, + usage: { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }, + }), + ); +} + +describe('openAiResponsesStream / aggregator', () => { + it('keeps reasoning separate from assistant text for normalized events and merges usage details', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const context = openAiResponsesStream.createContext('gpt-5'); + + const contentEvent = { + role: 'assistant', + contentDelta: 'hello', + }; + const reasoningEvent = { + reasoningDelta: 'plan first', + responsesPayload: { + usage: { + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + output_tokens_details: { reasoning_tokens: 2 }, + }, + }, + }; + + collectSerializedEvents( + serializeConvertedResponsesEvents({ + state, + streamContext: context, + event: contentEvent as any, + usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 }, + }), + ); + + const reasoningEmitted = collectSerializedEvents( + serializeConvertedResponsesEvents({ + state, + streamContext: context, + event: reasoningEvent as any, + usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 }, + }), + ); + + expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true); + expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_text.delta')).toBe(true); + + const completed = collectSerializedEvents( + completeResponsesStream(state, context, { promptTokens: 5, completionTokens: 3, totalTokens: 8 }), + ).find((item) => item.event === 'response.completed'); + + expect(completed?.data.response.output).toEqual([ + { + id: expect.any(String), + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'hello', + }, + ], + }, + { + id: expect.any(String), + type: 'reasoning', + status: 'completed', + summary: [ + { + type: 'summary_text', + text: 'plan first', + }, + ], + }, + ]); + expect(completed?.data.response.usage).toMatchObject({ + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + output_tokens_details: { reasoning_tokens: 2 }, + }); + }); + + it('tracks reasoning summary events into completed response output', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + + const emitted = [ + ...feedResponsesEvent(state, { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'rs_1', + type: 'reasoning', + status: 'in_progress', + summary: [], + }, + }), + ...feedResponsesEvent(state, { + type: 'response.reasoning_summary_part.added', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + part: { + type: 'summary_text', + text: '', + }, + }), + ...feedResponsesEvent(state, { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + delta: 'Plan', + }), + ...feedResponsesEvent(state, { + type: 'response.reasoning_summary_text.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + text: 'Plan it out', + }), + ...feedResponsesEvent(state, { + type: 'response.reasoning_summary_part.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + part: { + type: 'summary_text', + text: 'Plan it out', + }, + }), + ...feedResponsesEvent(state, { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'rs_1', + type: 'reasoning', + status: 'completed', + summary: [], + }, + }), + ...feedResponsesEvent(state, { + type: 'response.completed', + response: { + id: 'resp_reasoning', + object: 'response', + created_at: 1700000000, + status: 'completed', + model: 'gpt-5', + output: [], + }, + }), + ]; + + expect(emitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true); + expect(emitted.some((item) => item.event === 'response.reasoning_summary_text.done')).toBe(true); + + const completed = emitted.find((item) => item.event === 'response.completed'); + expect(completed?.data.response.output).toEqual([ + { + id: 'rs_1', + type: 'reasoning', + status: 'completed', + summary: [ + { + type: 'summary_text', + text: 'Plan it out', + }, + ], + }, + ]); + }); + + it('tracks custom tool input and image generation results', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + + const emitted = [ + ...feedResponsesEvent(state, { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'ct_1', + type: 'custom_tool_call', + status: 'in_progress', + call_id: 'ct_1', + name: 'browser', + input: '', + }, + }), + ...feedResponsesEvent(state, { + type: 'response.custom_tool_call_input.delta', + item_id: 'ct_1', + output_index: 0, + delta: 'open ', + }), + ...feedResponsesEvent(state, { + type: 'response.custom_tool_call_input.done', + item_id: 'ct_1', + output_index: 0, + input: 'open example.com', + }), + ...feedResponsesEvent(state, { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'ct_1', + type: 'custom_tool_call', + status: 'completed', + call_id: 'ct_1', + name: 'browser', + input: 'open example.com', + }, + }), + ...feedResponsesEvent(state, { + type: 'response.output_item.added', + output_index: 1, + item: { + id: 'img_1', + type: 'image_generation_call', + status: 'in_progress', + result: null, + }, + }), + ...feedResponsesEvent(state, { + type: 'response.image_generation_call.partial_image', + item_id: 'img_1', + output_index: 1, + partial_image_index: 0, + partial_image_b64: 'abc123', + }), + ...feedResponsesEvent(state, { + type: 'response.image_generation_call.completed', + item_id: 'img_1', + output_index: 1, + result: 'data:image/png;base64,final', + }), + ...feedResponsesEvent(state, { + type: 'response.output_item.done', + output_index: 1, + item: { + id: 'img_1', + type: 'image_generation_call', + status: 'completed', + result: 'data:image/png;base64,final', + }, + }), + ...feedResponsesEvent(state, { + type: 'response.completed', + response: { + id: 'resp_tools', + object: 'response', + created_at: 1700000001, + status: 'completed', + model: 'gpt-5', + output: [], + }, + }), + ]; + + expect(emitted.some((item) => item.event === 'response.custom_tool_call_input.done')).toBe(true); + expect(emitted.some((item) => item.event === 'response.image_generation_call.completed')).toBe(true); + + const completed = emitted.find((item) => item.event === 'response.completed'); + expect(completed?.data.response.output).toEqual([ + { + id: 'ct_1', + type: 'custom_tool_call', + status: 'completed', + call_id: 'ct_1', + name: 'browser', + input: 'open example.com', + }, + { + id: 'img_1', + type: 'image_generation_call', + status: 'completed', + result: 'data:image/png;base64,final', + partial_images: [ + { + partial_image_index: 0, + partial_image_b64: 'abc123', + }, + ], + }, + ]); + }); +}); diff --git a/src/server/transformers/openai/responses/aggregator.ts b/src/server/transformers/openai/responses/aggregator.ts new file mode 100644 index 0000000..3a07a22 --- /dev/null +++ b/src/server/transformers/openai/responses/aggregator.ts @@ -0,0 +1,762 @@ +import { type StreamTransformContext } from '../../shared/normalized.js'; +import type { OpenAiResponsesStreamEvent } from './stream.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function cloneRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + return { ...value }; +} + +function cloneJson(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJson(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJson(item)]), + ) as T; + } + return value; +} + +function ensureResponseId(rawId: string): string { + const trimmed = rawId.trim() || `resp_${Date.now()}`; + return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`; +} + +function ensureOutputItemId(rawId: string, prefix: string, index: number): string { + const trimmed = rawId.trim(); + if (trimmed) return trimmed; + return `${prefix}_${index}`; +} + +function serializeSse(event: string, payload: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; +} + +function serializeDone(): string { + return 'data: [DONE]\n\n'; +} + +type ResponsesUsageSummary = { + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + promptTokensIncludeCache?: boolean | null; +}; + +type AggregateOutputItem = Record; + +export type OpenAiResponsesAggregateState = { + modelName: string; + responseId: string | null; + createdAt: number | null; + outputItems: Array; + outputIndexById: Record; + messageIndex: number | null; + reasoningIndexById: Record; + functionIndexById: Record; + customToolIndexById: Record; + imageGenerationIndexById: Record; + usageExtras: Record; + completed: boolean; + failed: boolean; +}; + +export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState { + return { + modelName, + responseId: null, + createdAt: null, + outputItems: [], + outputIndexById: {}, + messageIndex: null, + reasoningIndexById: {}, + functionIndexById: {}, + customToolIndexById: {}, + imageGenerationIndexById: {}, + usageExtras: {}, + completed: false, + failed: false, + }; +} + +function mergeUsageExtras( + state: OpenAiResponsesAggregateState, + usagePayload: unknown, +): void { + if (!isRecord(usagePayload)) return; + for (const [key, value] of Object.entries(usagePayload)) { + if (key === 'input_tokens' || key === 'output_tokens' || key === 'total_tokens') continue; + state.usageExtras[key] = cloneJson(value); + } +} + +function rememberOutputId(state: OpenAiResponsesAggregateState, index: number, item: AggregateOutputItem) { + const itemId = asTrimmedString(item.id); + if (itemId) { + state.outputIndexById[itemId] = index; + } + const itemType = asTrimmedString(item.type).toLowerCase(); + const callId = asTrimmedString(item.call_id); + if (itemType === 'function_call') { + if (callId) state.functionIndexById[callId] = index; + if (itemId) state.functionIndexById[itemId] = index; + } + if (itemType === 'custom_tool_call') { + if (callId) state.customToolIndexById[callId] = index; + if (itemId) state.customToolIndexById[itemId] = index; + } + if (itemType === 'image_generation_call' && itemId) { + state.imageGenerationIndexById[itemId] = index; + } + if (itemType === 'reasoning' && itemId) { + state.reasoningIndexById[itemId] = index; + } + if (itemType === 'message' && state.messageIndex === null) { + state.messageIndex = index; + } +} + +function setOutputItem( + state: OpenAiResponsesAggregateState, + index: number, + item: AggregateOutputItem, +): AggregateOutputItem { + const current = cloneRecord(state.outputItems[index]) || {}; + const incoming = cloneJson(item); + const next = { + ...current, + ...incoming, + }; + if (Array.isArray(current.summary) && (!Array.isArray(incoming.summary) || incoming.summary.length <= 0)) { + next.summary = current.summary; + } + if (Array.isArray(current.content) && (!Array.isArray(incoming.content) || incoming.content.length <= 0)) { + next.content = current.content; + } + if ( + Array.isArray(current.partial_images) + && (!Array.isArray(incoming.partial_images) || incoming.partial_images.length <= 0) + ) { + next.partial_images = current.partial_images; + } + state.outputItems[index] = next; + rememberOutputId(state, index, next); + return next; +} + +function ensureOutputItem( + state: OpenAiResponsesAggregateState, + index: number, + factory: () => AggregateOutputItem, +): AggregateOutputItem { + const existing = state.outputItems[index]; + if (existing) return existing; + return setOutputItem(state, index, factory()); +} + +function resolveOutputIndex( + state: OpenAiResponsesAggregateState, + rawIndex: unknown, + ...candidateIds: Array +): number { + if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) { + return Math.max(0, Math.trunc(rawIndex)); + } + for (const rawId of candidateIds) { + const itemId = asTrimmedString(rawId); + if (itemId && state.outputIndexById[itemId] !== undefined) { + return state.outputIndexById[itemId]; + } + } + return state.outputItems.length; +} + +function ensureMessageItem(state: OpenAiResponsesAggregateState, indexHint?: number): { index: number; item: AggregateOutputItem } { + const index = state.messageIndex ?? indexHint ?? 0; + const item = ensureOutputItem(state, index, () => ({ + id: ensureOutputItemId('', 'msg', index), + type: 'message', + role: 'assistant', + status: 'in_progress', + content: [], + })); + if (!Array.isArray(item.content)) item.content = []; + if (state.messageIndex === null) state.messageIndex = index; + return { index, item }; +} + +function ensureMessageOutputTextPart( + state: OpenAiResponsesAggregateState, + indexHint?: number, +): { index: number; item: AggregateOutputItem; part: AggregateOutputItem; created: boolean } { + const { index, item } = ensureMessageItem(state, indexHint); + const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : []; + if (!Array.isArray(item.content)) item.content = content; + let part = content[0]; + const created = !isRecord(part) || asTrimmedString(part.type).toLowerCase() !== 'output_text'; + if (created) { + part = { type: 'output_text', text: '' }; + content[0] = part; + } + return { index, item, part, created }; +} + +function ensureReasoningItem( + state: OpenAiResponsesAggregateState, + itemIdRaw: unknown, + indexHint?: unknown, +): { index: number; item: AggregateOutputItem; created: boolean } { + const itemId = asTrimmedString(itemIdRaw); + const existingIndex = itemId ? state.reasoningIndexById[itemId] : undefined; + const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId); + const created = !state.outputItems[index]; + const item = ensureOutputItem(state, index, () => ({ + id: ensureOutputItemId(itemId, 'rs', index), + type: 'reasoning', + status: 'in_progress', + summary: [], + })); + if (!Array.isArray(item.summary)) item.summary = []; + return { index, item, created }; +} + +function ensureReasoningSummaryPart( + state: OpenAiResponsesAggregateState, + itemIdRaw: unknown, + summaryIndexRaw: unknown, + indexHint?: unknown, +): { item: AggregateOutputItem; summary: AggregateOutputItem; index: number; summaryIndex: number; created: boolean } { + const reasoningState = ensureReasoningItem(state, itemIdRaw, indexHint); + const summaryIndex = typeof summaryIndexRaw === 'number' && Number.isFinite(summaryIndexRaw) + ? Math.max(0, Math.trunc(summaryIndexRaw)) + : 0; + const summary = Array.isArray(reasoningState.item.summary) ? reasoningState.item.summary as AggregateOutputItem[] : []; + if (!Array.isArray(reasoningState.item.summary)) reasoningState.item.summary = summary; + let part = summary[summaryIndex]; + const created = !isRecord(part); + if (!isRecord(part)) { + part = { type: 'summary_text', text: '' }; + summary[summaryIndex] = part; + } + return { item: reasoningState.item, summary: part, index: reasoningState.index, summaryIndex, created: reasoningState.created || created }; +} + +function ensureFunctionCallItem( + state: OpenAiResponsesAggregateState, + callIdRaw: unknown, + nameRaw: unknown, + indexHint?: number, +): { index: number; item: AggregateOutputItem; created: boolean } { + const callId = asTrimmedString(callIdRaw); + const name = asTrimmedString(nameRaw); + const existingIndex = callId ? state.functionIndexById[callId] : undefined; + const index = existingIndex ?? resolveOutputIndex(state, indexHint, callId); + const created = !state.outputItems[index]; + const item = ensureOutputItem(state, index, () => ({ + id: ensureOutputItemId(callId, 'fc', index), + type: 'function_call', + status: 'in_progress', + call_id: ensureOutputItemId(callId, 'call', index), + name, + arguments: '', + })); + if (callId) item.call_id = ensureOutputItemId(callId, 'call', index); + if (name) item.name = name; + if (typeof item.arguments !== 'string') item.arguments = ''; + return { index, item, created }; +} + +function ensureCustomToolItem( + state: OpenAiResponsesAggregateState, + itemIdRaw: unknown, + callIdRaw: unknown, + nameRaw: unknown, + indexHint?: number, +): { index: number; item: AggregateOutputItem; created: boolean } { + const itemId = asTrimmedString(itemIdRaw); + const callId = asTrimmedString(callIdRaw); + const name = asTrimmedString(nameRaw); + const existingIndex = (callId && state.customToolIndexById[callId] !== undefined) + ? state.customToolIndexById[callId] + : (itemId && state.customToolIndexById[itemId] !== undefined ? state.customToolIndexById[itemId] : undefined); + const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId, callId); + const created = !state.outputItems[index]; + const item = ensureOutputItem(state, index, () => ({ + id: ensureOutputItemId(itemId, 'ct', index), + type: 'custom_tool_call', + status: 'in_progress', + call_id: ensureOutputItemId(callId || itemId, 'call', index), + name, + input: '', + })); + if (callId || itemId) item.call_id = ensureOutputItemId(callId || itemId, 'call', index); + if (name) item.name = name; + if (typeof item.input !== 'string') item.input = ''; + return { index, item, created }; +} + +function ensureImageGenerationItem( + state: OpenAiResponsesAggregateState, + itemIdRaw: unknown, + indexHint?: number, +): { index: number; item: AggregateOutputItem; created: boolean } { + const itemId = asTrimmedString(itemIdRaw); + const existingIndex = itemId ? state.imageGenerationIndexById[itemId] : undefined; + const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId); + const created = !state.outputItems[index]; + const item = ensureOutputItem(state, index, () => ({ + id: ensureOutputItemId(itemId, 'img', index), + type: 'image_generation_call', + status: 'in_progress', + result: null, + partial_images: [], + })); + if (!Array.isArray(item.partial_images)) item.partial_images = []; + return { index, item, created }; +} + +function buildUsagePayload(usage: ResponsesUsageSummary): Record { + const payload: Record = { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }; + const inputDetails: Record = {}; + if ((usage.cacheReadTokens || 0) > 0) inputDetails.cached_tokens = usage.cacheReadTokens; + if ((usage.cacheCreationTokens || 0) > 0) inputDetails.cache_creation_tokens = usage.cacheCreationTokens; + if (Object.keys(inputDetails).length > 0) payload.input_tokens_details = inputDetails; + return { + ...payload, + }; +} + +function collectOutputText(state: OpenAiResponsesAggregateState): string { + const parts: string[] = []; + for (const item of state.outputItems) { + if (!isRecord(item)) continue; + if (asTrimmedString(item.type).toLowerCase() !== 'message') continue; + const content = Array.isArray(item.content) ? item.content : []; + for (const part of content) { + if (!isRecord(part)) continue; + const type = asTrimmedString(part.type).toLowerCase(); + if ((type === 'output_text' || type === 'text') && typeof part.text === 'string' && part.text) { + parts.push(part.text); + } + } + } + return parts.join(''); +} + +function materializeResponse( + state: OpenAiResponsesAggregateState, + streamContext: StreamTransformContext, + usage: ResponsesUsageSummary, + responseTemplate?: Record | null, + statusOverride?: 'completed' | 'failed', +): Record { + const base = cloneRecord(responseTemplate) || {}; + const responseId = ensureResponseId( + asTrimmedString(base.id) + || state.responseId + || streamContext.id + || state.modelName, + ); + const createdAt = ( + typeof base.created_at === 'number' && Number.isFinite(base.created_at) + ? base.created_at + : (typeof base.created === 'number' && Number.isFinite(base.created) ? base.created : null) + ) ?? state.createdAt ?? Math.floor(Date.now() / 1000); + const output = state.outputItems + .filter((item): item is AggregateOutputItem => isRecord(item)) + .map((item) => { + const status = asTrimmedString(item.status).toLowerCase(); + return { + ...item, + status: status && status !== 'in_progress' + ? status + : (state.failed ? 'failed' : 'completed'), + }; + }); + + return { + ...base, + id: responseId, + object: 'response', + created_at: createdAt, + status: statusOverride ?? (state.failed ? 'failed' : 'completed'), + model: asTrimmedString(base.model) || streamContext.model || state.modelName, + output, + output_text: collectOutputText(state), + usage: buildUsagePayload(usage), + ...Object.keys(state.usageExtras).length > 0 ? { usage: { ...buildUsagePayload(usage), ...state.usageExtras } } : {}, + }; +} + +function serializeOriginalResponsesEvent(eventType: string, payload: Record): string[] { + return [serializeSse(eventType, payload)]; +} + +function computeNovelDelta(existingText: string, incomingDelta: string): string { + if (!incomingDelta) return ''; + if (!existingText) return incomingDelta; + if (existingText.endsWith(incomingDelta)) return ''; + if (incomingDelta.startsWith(existingText)) { + return incomingDelta.slice(existingText.length); + } + if (existingText.includes(incomingDelta)) return ''; + + const maxOverlap = Math.min(existingText.length, incomingDelta.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + if (existingText.slice(-overlap) === incomingDelta.slice(0, overlap)) { + return incomingDelta.slice(overlap); + } + } + return incomingDelta; +} + +function applyOriginalResponsesPayload( + state: OpenAiResponsesAggregateState, + eventType: string, + payload: Record, + streamContext: StreamTransformContext, + usage: ResponsesUsageSummary, +): string[] { + switch (eventType) { + case 'response.output_item.added': + case 'response.output_item.done': { + const outputIndex = resolveOutputIndex(state, payload.output_index, (payload.item as Record | undefined)?.id); + const item = cloneRecord(payload.item) || {}; + if (Object.keys(item).length > 0) { + const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {}; + const next = { + ...existing, + ...item, + }; + if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) { + next.content = existing.content; + } + if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(existing.summary)) { + next.summary = existing.summary; + } + if ((!Array.isArray(item.partial_images) || item.partial_images.length === 0) && Array.isArray(existing.partial_images)) { + next.partial_images = existing.partial_images; + } + setOutputItem(state, outputIndex, next); + } + return serializeOriginalResponsesEvent(eventType, { + ...payload, + item: state.outputItems[outputIndex] ?? payload.item, + }); + } + case 'response.content_part.added': + case 'response.content_part.done': { + const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id); + const contentIndex = typeof payload.content_index === 'number' && Number.isFinite(payload.content_index) + ? Math.max(0, Math.trunc(payload.content_index)) + : 0; + const message = ensureMessageItem(state, outputIndex).item; + const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : []; + if (!Array.isArray(message.content)) message.content = content; + const part = cloneRecord(payload.part); + if (part) content[contentIndex] = part; + return serializeOriginalResponsesEvent(eventType, payload); + } + case 'response.output_text.delta': + case 'response.output_text.done': { + const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id); + const textPart = ensureMessageOutputTextPart(state, outputIndex); + if (eventType === 'response.output_text.done') { + textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? ''); + } else { + textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; + } + return serializeOriginalResponsesEvent(eventType, payload); + } + case 'response.function_call_arguments.delta': + case 'response.function_call_arguments.done': { + const entry = ensureFunctionCallItem( + state, + payload.call_id ?? payload.item_id, + payload.name, + resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id), + ); + if (eventType === 'response.function_call_arguments.done') { + entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? ''); + } else { + entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; + } + return serializeOriginalResponsesEvent(eventType, payload); + } + case 'response.custom_tool_call_input.delta': + case 'response.custom_tool_call_input.done': { + const entry = ensureCustomToolItem( + state, + payload.item_id, + payload.call_id, + payload.name, + resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id), + ); + if (eventType === 'response.custom_tool_call_input.done') { + entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? ''); + } else { + entry.item.input = `${typeof entry.item.input === 'string' ? entry.item.input : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; + } + return serializeOriginalResponsesEvent(eventType, payload); + } + case 'response.reasoning_summary_part.added': + case 'response.reasoning_summary_part.done': { + const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index); + const part = cloneRecord(payload.part); + if (part) { + const summary = Array.isArray(summaryState.item.summary) ? summaryState.item.summary as AggregateOutputItem[] : []; + summary[summaryState.summaryIndex] = { + ...summaryState.summary, + ...part, + }; + summaryState.item.summary = summary; + } + return serializeOriginalResponsesEvent(eventType, payload); + } + case 'response.reasoning_summary_text.delta': + case 'response.reasoning_summary_text.done': { + const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index); + if (eventType === 'response.reasoning_summary_text.done') { + summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? ''); + } else { + summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; + } + return serializeOriginalResponsesEvent(eventType, payload); + } + case 'response.image_generation_call.generating': + case 'response.image_generation_call.in_progress': + case 'response.image_generation_call.partial_image': + case 'response.image_generation_call.completed': { + const entry = ensureImageGenerationItem( + state, + payload.item_id, + resolveOutputIndex(state, payload.output_index, payload.item_id), + ); + if (eventType === 'response.image_generation_call.partial_image') { + const partialImages = Array.isArray(entry.item.partial_images) ? entry.item.partial_images as AggregateOutputItem[] : []; + partialImages.push({ + partial_image_index: payload.partial_image_index, + partial_image_b64: payload.partial_image_b64, + }); + entry.item.partial_images = partialImages; + } + if (payload.result !== undefined) { + entry.item.result = payload.result; + } + if (eventType === 'response.image_generation_call.completed') { + entry.item.status = 'completed'; + } + return serializeOriginalResponsesEvent(eventType, payload); + } + case 'response.completed': { + mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage); + state.completed = true; + const responsePayload = cloneRecord(payload.response); + const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed'); + return [serializeSse('response.completed', { ...payload, response: materialized })]; + } + case 'response.failed': { + mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage); + state.failed = true; + const responsePayload = cloneRecord(payload.response); + const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed'); + return [serializeSse('response.failed', { ...payload, response: materialized })]; + } + default: + mergeUsageExtras(state, payload.usage); + return serializeOriginalResponsesEvent(eventType, payload); + } +} + +function buildSyntheticMessageEvents( + state: OpenAiResponsesAggregateState, + delta: string, +): string[] { + const { index, item } = ensureMessageItem(state); + const textPartState = ensureMessageOutputTextPart(state, index); + const lines: string[] = []; + const currentText = typeof textPartState.part.text === 'string' ? textPartState.part.text : ''; + const novelDelta = computeNovelDelta(currentText, delta); + if (textPartState.created) { + lines.push(serializeSse('response.output_item.added', { + type: 'response.output_item.added', + output_index: index, + item, + })); + lines.push(serializeSse('response.content_part.added', { + type: 'response.content_part.added', + output_index: index, + item_id: item.id, + content_index: 0, + part: { type: 'output_text', text: '' }, + })); + } + if (novelDelta) { + textPartState.part.text = `${currentText}${novelDelta}`; + lines.push(serializeSse('response.output_text.delta', { + type: 'response.output_text.delta', + output_index: index, + item_id: item.id, + delta: novelDelta, + })); + } + return lines; +} + +function buildSyntheticReasoningEvents( + state: OpenAiResponsesAggregateState, + delta: string, +): string[] { + const summaryState = ensureReasoningSummaryPart(state, '', 0, state.outputItems.length); + const itemId = asTrimmedString(summaryState.item.id); + const lines: string[] = []; + const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''; + const novelDelta = computeNovelDelta(currentText, delta); + if (summaryState.created) { + lines.push(serializeSse('response.output_item.added', { + type: 'response.output_item.added', + output_index: summaryState.index, + item: summaryState.item, + })); + lines.push(serializeSse('response.reasoning_summary_part.added', { + type: 'response.reasoning_summary_part.added', + item_id: itemId, + output_index: summaryState.index, + summary_index: 0, + part: { type: 'summary_text', text: '' }, + })); + } + if (novelDelta) { + summaryState.summary.text = `${currentText}${novelDelta}`; + lines.push(serializeSse('response.reasoning_summary_text.delta', { + type: 'response.reasoning_summary_text.delta', + item_id: itemId, + output_index: summaryState.index, + summary_index: 0, + delta: novelDelta, + })); + } + return lines; +} + +function buildSyntheticToolEvents( + state: OpenAiResponsesAggregateState, + event: OpenAiResponsesStreamEvent, +): string[] { + const lines: string[] = []; + if (!Array.isArray(event.toolCallDeltas)) return lines; + for (const toolDelta of event.toolCallDeltas) { + const entry = ensureFunctionCallItem(state, toolDelta.id, toolDelta.name, toolDelta.index); + if (entry.created) { + lines.push(serializeSse('response.output_item.added', { + type: 'response.output_item.added', + output_index: entry.index, + item: entry.item, + })); + } + if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) { + entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${toolDelta.argumentsDelta}`; + lines.push(serializeSse('response.function_call_arguments.delta', { + type: 'response.function_call_arguments.delta', + item_id: entry.item.id, + call_id: entry.item.call_id, + output_index: entry.index, + name: entry.item.name, + delta: toolDelta.argumentsDelta, + })); + } + } + return lines; +} + +export function serializeConvertedResponsesEvents(input: { + state: OpenAiResponsesAggregateState; + streamContext: StreamTransformContext; + event: OpenAiResponsesStreamEvent; + usage: ResponsesUsageSummary; +}): string[] { + const { state, streamContext, event, usage } = input; + mergeUsageExtras(state, event.responsesPayload && isRecord(event.responsesPayload) ? event.responsesPayload.usage : undefined); + + if (event.responsesEventType && event.responsesPayload) { + return applyOriginalResponsesPayload( + state, + event.responsesEventType, + event.responsesPayload, + streamContext, + usage, + ); + } + + const lines: string[] = []; + if (event.contentDelta) { + lines.push(...buildSyntheticMessageEvents(state, event.contentDelta)); + } + if (event.reasoningDelta) { + lines.push(...buildSyntheticReasoningEvents(state, event.reasoningDelta)); + } + lines.push(...buildSyntheticToolEvents(state, event)); + return lines; +} + +export function completeResponsesStream( + state: OpenAiResponsesAggregateState, + streamContext: StreamTransformContext, + usage: ResponsesUsageSummary, +): string[] { + if (state.failed || state.completed) { + return [serializeDone()]; + } + state.completed = true; + return [ + serializeSse('response.completed', { + type: 'response.completed', + response: materializeResponse(state, streamContext, usage, null, 'completed'), + }), + serializeDone(), + ]; +} + +export function failResponsesStream( + state: OpenAiResponsesAggregateState, + streamContext: StreamTransformContext, + usage: ResponsesUsageSummary, + payload: unknown, +): string[] { + if (state.failed) { + return [serializeDone()]; + } + state.failed = true; + const errorPayload = cloneRecord(payload); + const message = ( + isRecord(errorPayload?.error) && typeof errorPayload.error.message === 'string' + ? errorPayload.error.message + : (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed') + ); + return [ + serializeSse('response.failed', { + type: 'response.failed', + response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'), + error: { + message, + type: 'upstream_error', + }, + }), + serializeDone(), + ]; +} diff --git a/src/server/transformers/openai/responses/compatibility.ts b/src/server/transformers/openai/responses/compatibility.ts new file mode 100644 index 0000000..8dc51fa --- /dev/null +++ b/src/server/transformers/openai/responses/compatibility.ts @@ -0,0 +1,409 @@ +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function toTextBlockType(role: string): 'input_text' | 'output_text' { + return role === 'assistant' ? 'output_text' : 'input_text'; +} + +function normalizeImageUrlValue(value: unknown): string | Record | null { + if (typeof value === 'string' && value.trim().length > 0) return value; + if (!isRecord(value)) return null; + const url = asTrimmedString(value.url); + if (url) return { ...value, url }; + const imageUrl = asTrimmedString(value.image_url); + if (imageUrl) return imageUrl; + return Object.keys(value).length > 0 ? value : null; +} + +function normalizeAudioInputValue(value: unknown): Record | null { + if (!isRecord(value)) return null; + const data = asTrimmedString(value.data); + const format = asTrimmedString(value.format); + if (!data && !format) return Object.keys(value).length > 0 ? value : null; + return { + ...value, + ...(data ? { data } : {}), + ...(format ? { format } : {}), + }; +} + +function normalizeResponsesContentItem( + item: unknown, + role: string, +): Record | null { + if (typeof item === 'string') { + const text = item.trim(); + return text ? { type: toTextBlockType(role), text } : null; + } + + if (!isRecord(item)) return null; + + const type = asTrimmedString(item.type).toLowerCase(); + if (!type) { + const text = asTrimmedString(item.text ?? item.content ?? item.output_text); + return text ? { type: toTextBlockType(role), text } : null; + } + + if (type === 'input_text' || type === 'output_text' || type === 'text') { + const text = asTrimmedString(item.text ?? item.content ?? item.output_text); + if (!text) return null; + return { + ...item, + type: type === 'text' ? toTextBlockType(role) : type, + text, + }; + } + + if (type === 'input_image' || type === 'image_url') { + const imageUrl = normalizeImageUrlValue(item.image_url ?? item.url); + if (!imageUrl) return null; + return { + ...item, + type: 'input_image', + image_url: imageUrl, + }; + } + + if (type === 'input_audio') { + const inputAudio = normalizeAudioInputValue(item.input_audio); + if (!inputAudio) return null; + return { + ...item, + type: 'input_audio', + input_audio: inputAudio, + }; + } + + if (type === 'function_call' || type === 'function_call_output') { + return item; + } + + return item; +} + +export function normalizeResponsesMessageContent(content: unknown, role: string): unknown { + if (Array.isArray(content)) { + const normalized = content + .map((item) => normalizeResponsesContentItem(item, role)) + .filter((item): item is Record => !!item); + return normalized.length > 0 ? normalized : content; + } + + const single = normalizeResponsesContentItem(content, role); + if (single) return [single]; + return content; +} + +function toResponsesInputMessageFromText(text: string): Record { + return { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text }], + }; +} + +export function normalizeResponsesMessageItem(item: Record): Record { + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'function_call' || type === 'function_call_output') { + return item; + } + + const role = asTrimmedString(item.role).toLowerCase() || 'user'; + const normalizedContent = normalizeResponsesMessageContent(item.content ?? item.text, role); + + if (type === 'message') { + return { + ...item, + role, + content: normalizedContent, + }; + } + + if (asTrimmedString(item.role)) { + return { + type: 'message', + role, + content: normalizedContent, + }; + } + + if (typeof item.content === 'string') { + const text = item.content.trim(); + return text ? toResponsesInputMessageFromText(text) : item; + } + + return item; +} + +export function normalizeResponsesInputForCompatibility(input: unknown): unknown { + if (typeof input === 'string') { + const normalized = input.trim(); + if (!normalized) return input; + return [toResponsesInputMessageFromText(normalized)]; + } + + if (Array.isArray(input)) { + return input.map((item) => { + if (typeof item === 'string') { + const normalized = item.trim(); + return normalized ? toResponsesInputMessageFromText(normalized) : item; + } + if (!isRecord(item)) return item; + return normalizeResponsesMessageItem(item); + }); + } + + if (isRecord(input)) { + return [normalizeResponsesMessageItem(input)]; + } + + return input; +} + +function extractTextFromResponsesContent(content: unknown): string { + if (typeof content === 'string') return content.trim(); + if (!Array.isArray(content)) return ''; + return content + .map((item) => { + if (!isRecord(item)) return ''; + return asTrimmedString(item.text ?? item.content ?? item.output_text); + }) + .filter((item) => item.length > 0) + .join('\n'); +} + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function normalizeOpenAiToolArguments(raw: unknown): string { + if (typeof raw === 'string') return raw; + if (raw === undefined || raw === null) return ''; + if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); + if (Array.isArray(raw) || isRecord(raw)) { + return safeJsonStringify(raw); + } + return ''; +} + +function normalizeToolMessageContent(raw: unknown): string { + if (raw === undefined || raw === null) return ''; + if (typeof raw === 'string') return raw; + if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); + if (Array.isArray(raw)) { + const normalized = normalizeResponsesMessageContent(raw, 'user'); + const text = extractTextFromResponsesContent(normalized); + return text || safeJsonStringify(raw); + } + if (isRecord(raw)) { + const normalized = normalizeResponsesMessageContent(raw, 'user'); + const text = extractTextFromResponsesContent(normalized); + return text || safeJsonStringify(raw); + } + return ''; +} + +function toFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +const ALLOWED_RESPONSES_FIELDS = new Set([ + 'model', + 'input', + 'instructions', + 'max_output_tokens', + 'max_completion_tokens', + 'temperature', + 'top_p', + 'truncation', + 'tools', + 'tool_choice', + 'parallel_tool_calls', + 'metadata', + 'reasoning', + 'store', + 'stream', + 'user', + 'previous_response_id', + 'text', + 'audio', + 'include', + 'response_format', + 'service_tier', + 'stop', + 'n', +]); + +export function convertOpenAiBodyToResponsesBody( + openaiBody: Record, + modelName: string, + stream: boolean, +): Record { + const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; + const systemContents: string[] = []; + const inputItems: Array> = []; + + for (const item of rawMessages) { + if (!isRecord(item)) continue; + const role = asTrimmedString(item.role).toLowerCase() || 'user'; + + if (role === 'system' || role === 'developer') { + const normalized = normalizeResponsesMessageContent(item.content, 'user'); + const content = extractTextFromResponsesContent(normalized).trim(); + if (content) systemContents.push(content); + continue; + } + + if (role === 'assistant') { + const normalizedAssistantContent = normalizeResponsesMessageContent(item.content, 'assistant'); + if (Array.isArray(normalizedAssistantContent) && normalizedAssistantContent.length > 0) { + inputItems.push({ + type: 'message', + role: 'assistant', + content: normalizedAssistantContent, + }); + } + + const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; + for (let index = 0; index < rawToolCalls.length; index += 1) { + const toolCall = rawToolCalls[index]; + if (!isRecord(toolCall)) continue; + const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; + const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`; + const name = ( + asTrimmedString(functionPart.name) + || asTrimmedString(toolCall.name) + || `tool_${index}` + ); + const argumentsValue = normalizeOpenAiToolArguments( + functionPart.arguments ?? toolCall.arguments, + ); + + inputItems.push({ + type: 'function_call', + call_id: callId, + name, + arguments: argumentsValue, + }); + } + continue; + } + + if (role === 'tool') { + const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id); + if (!callId) continue; + + inputItems.push({ + type: 'function_call_output', + call_id: callId, + output: normalizeToolMessageContent(item.content), + }); + continue; + } + + const normalizedUserContent = normalizeResponsesMessageContent(item.content, 'user'); + if (Array.isArray(normalizedUserContent) && normalizedUserContent.length > 0) { + inputItems.push({ + type: 'message', + role: 'user', + content: normalizedUserContent, + }); + } + } + + const maxOutputTokens = ( + toFiniteNumber(openaiBody.max_output_tokens) + ?? toFiniteNumber(openaiBody.max_completion_tokens) + ?? toFiniteNumber(openaiBody.max_tokens) + ?? 4096 + ); + + const body: Record = { + model: modelName, + stream, + max_output_tokens: maxOutputTokens, + input: inputItems, + }; + + if (systemContents.length > 0) { + body.instructions = systemContents.join('\n\n'); + } + + const temperature = toFiniteNumber(openaiBody.temperature); + if (temperature !== null) body.temperature = temperature; + + const topP = toFiniteNumber(openaiBody.top_p); + if (topP !== null) body.top_p = topP; + + if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata; + if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning; + if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls; + if (openaiBody.tools !== undefined) body.tools = openaiBody.tools; + if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice; + + return { + ...body, + input: normalizeResponsesInputForCompatibility(body.input), + }; +} + +export function sanitizeResponsesBodyForProxy( + body: Record, + modelName: string, + stream: boolean, +): Record { + let normalized: Record = { + ...body, + model: modelName, + stream, + }; + + if (normalized.input === undefined) { + if (Array.isArray((normalized as Record).messages)) { + normalized = convertOpenAiBodyToResponsesBody(normalized, modelName, stream); + } else { + const prompt = asTrimmedString((normalized as Record).prompt); + if (prompt) { + normalized = { + ...normalized, + input: [toResponsesInputMessageFromText(prompt)], + }; + } + } + } else { + normalized = { + ...normalized, + input: normalizeResponsesInputForCompatibility(normalized.input), + }; + } + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(normalized)) { + if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue; + if (key === 'max_completion_tokens') continue; + sanitized[key] = value; + } + + const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens); + if (maxOutputTokens !== null && maxOutputTokens > 0) { + sanitized.max_output_tokens = Math.trunc(maxOutputTokens); + } else { + const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens); + if (maxCompletionTokens !== null && maxCompletionTokens > 0) { + sanitized.max_output_tokens = Math.trunc(maxCompletionTokens); + } + } + + sanitized.model = modelName; + sanitized.stream = stream; + return sanitized; +} diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts new file mode 100644 index 0000000..a359f25 --- /dev/null +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; + +import { + convertOpenAiBodyToResponsesBody, + convertResponsesBodyToOpenAiBody, + sanitizeResponsesBodyForProxy, +} from './conversion.js'; + +describe('sanitizeResponsesBodyForProxy', () => { + it('preserves newer Responses request fields needed by the proxy', () => { + const result = sanitizeResponsesBodyForProxy( + { + model: 'gpt-5', + input: 'hello', + safety_identifier: 'safe-user-1', + max_tool_calls: 3, + prompt_cache_key: 'cache-key', + prompt_cache_retention: { scope: 'session' }, + stream_options: { include_obfuscation: true }, + background: true, + text: { format: { type: 'text' }, verbosity: 'high' }, + }, + 'gpt-5', + true, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + stream: true, + safety_identifier: 'safe-user-1', + max_tool_calls: 3, + prompt_cache_key: 'cache-key', + prompt_cache_retention: { scope: 'session' }, + stream_options: { include_obfuscation: true }, + background: true, + text: { format: { type: 'text' }, verbosity: 'high' }, + }); + }); +}); + +describe('convertOpenAiBodyToResponsesBody', () => { + it('maps extra request fields and preserves custom/image_generation tools', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'draw a cat' }], + safety_identifier: 'safe-user-2', + max_tool_calls: 2, + prompt_cache_key: 'prompt-key', + prompt_cache_retention: { scope: 'workspace' }, + stream_options: { include_obfuscation: true }, + background: false, + verbosity: 'low', + tools: [ + { + type: 'custom', + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }, + { + type: 'image_generation', + background: 'transparent', + size: '1024x1024', + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + stream: false, + safety_identifier: 'safe-user-2', + max_tool_calls: 2, + prompt_cache_key: 'prompt-key', + prompt_cache_retention: { scope: 'workspace' }, + stream_options: { include_obfuscation: true }, + background: false, + text: { verbosity: 'low' }, + tools: [ + { + type: 'custom', + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }, + { + type: 'image_generation', + background: 'transparent', + size: '1024x1024', + }, + ], + }); + }); +}); + +describe('convertResponsesBodyToOpenAiBody', () => { + it('preserves richer Responses request fields back onto the OpenAI-compatible body', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] }], + safety_identifier: 'safe-user-3', + max_tool_calls: 4, + prompt_cache_key: 'prompt-key-2', + prompt_cache_retention: { scope: 'project' }, + stream_options: { include_obfuscation: true }, + background: true, + text: { format: { type: 'json_object' }, verbosity: 'high' }, + tools: [ + { + type: 'custom', + name: 'browser', + format: { type: 'grammar', syntax: 'lark' }, + }, + { + type: 'image_generation', + background: 'transparent', + partial_images: 2, + output_format: 'png', + }, + ], + }, + 'gpt-5', + true, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + stream: true, + safety_identifier: 'safe-user-3', + max_tool_calls: 4, + prompt_cache_key: 'prompt-key-2', + prompt_cache_retention: { scope: 'project' }, + stream_options: { include_obfuscation: true }, + background: true, + verbosity: 'high', + tools: [ + { + type: 'custom', + name: 'browser', + format: { type: 'grammar', syntax: 'lark' }, + }, + { + type: 'image_generation', + background: 'transparent', + partial_images: 2, + output_format: 'png', + }, + ], + }); + }); + + it('converts custom tool calls and outputs into OpenAI-compatible tool messages', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: [ + { + type: 'custom_tool_call', + id: 'ct_1', + call_id: 'ct_1', + name: 'browser', + input: 'open example.com', + }, + { + type: 'custom_tool_call_output', + call_id: 'ct_1', + output: 'done', + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'ct_1', + type: 'function', + function: { + name: 'browser', + arguments: 'open example.com', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'ct_1', + content: 'done', + }, + ]); + }); + + it('converts reasoning items back into assistant content instead of dropping them', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: [ + { + type: 'reasoning', + id: 'rs_1', + status: 'completed', + summary: [ + { type: 'summary_text', text: 'Think step by step' }, + ], + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Think step by step', + }, + ], + }, + ]); + }); + + it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: 'hello', + user: 'user-123', + include: ['reasoning.encrypted_content'], + previous_response_id: 'resp_prev', + truncation: 'auto', + service_tier: 'priority', + top_logprobs: 4, + }, + 'gpt-5', + true, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + stream: true, + user: 'user-123', + include: ['reasoning.encrypted_content'], + previous_response_id: 'resp_prev', + truncation: 'auto', + service_tier: 'priority', + top_logprobs: 4, + }); + }); +}); diff --git a/src/server/transformers/openai/responses/conversion.ts b/src/server/transformers/openai/responses/conversion.ts new file mode 100644 index 0000000..2ce4582 --- /dev/null +++ b/src/server/transformers/openai/responses/conversion.ts @@ -0,0 +1,698 @@ +import { + normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility, + normalizeResponsesMessageItem, +} from './compatibility.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function toFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function cloneRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + return { ...value }; +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +function parseJsonString(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { value: raw }; + } +} + +export function extractTextContent(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value + .map((item) => extractTextContent(item)) + .filter((item) => item.length > 0) + .join('\n'); + } + if (!isRecord(value)) return ''; + + const direct = asTrimmedString( + value.text + ?? value.content + ?? value.input_text + ?? value.output_text + ?? value.reasoning + ?? value.reasoning_content + ?? value.thinking, + ); + if (direct) return direct; + + if (Array.isArray(value.parts)) return extractTextContent(value.parts); + if (Array.isArray(value.content)) return extractTextContent(value.content); + if (Array.isArray(value.output)) return extractTextContent(value.output); + return ''; +} + +function normalizeOpenAiToolArguments(raw: unknown): string { + if (typeof raw === 'string') return raw; + if (raw === undefined || raw === null) return ''; + if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); + if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw); + return ''; +} + +function normalizeToolOutput(raw: unknown): string { + const text = extractTextContent(raw).trim(); + if (text) return text; + if (raw === undefined || raw === null) return ''; + if (typeof raw === 'string') return raw; + if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); + if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw); + return ''; +} + +export function normalizeResponsesInputForCompatibility(input: unknown): unknown { + return normalizeResponsesInputForCompatibilityViaCompatibility(input); +} + +function toResponsesInputMessageFromText(text: string): Record { + return { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text }], + }; +} + +function convertOpenAiToolsToResponses(rawTools: unknown): unknown { + if (!Array.isArray(rawTools)) return rawTools; + + const converted = rawTools + .map((item) => { + if (!isRecord(item)) return null; + + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'function' && isRecord(item.function)) { + const fn = item.function; + const name = asTrimmedString(fn.name); + if (!name) return null; + + const mapped: Record = { + type: 'function', + name, + }; + const description = asTrimmedString(fn.description); + if (description) mapped.description = description; + if (fn.parameters !== undefined) mapped.parameters = fn.parameters; + if (fn.strict !== undefined) mapped.strict = fn.strict; + return mapped; + } + + if (type === 'function' && asTrimmedString(item.name)) { + return item; + } + + if (type === 'image_generation') { + return item; + } + + if (type === 'custom' && asTrimmedString(item.name)) { + return item; + } + + return null; + }) + .filter((item): item is Record => !!item); + + return converted.length > 0 ? converted : rawTools; +} + +function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown { + if (rawToolChoice === undefined) return undefined; + if (typeof rawToolChoice === 'string') return rawToolChoice; + if (!isRecord(rawToolChoice)) return rawToolChoice; + + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'function' && isRecord(rawToolChoice.function)) { + const name = asTrimmedString(rawToolChoice.function.name); + if (!name) return 'required'; + return { type: 'function', name }; + } + + return rawToolChoice; +} + +function normalizeResponsesBodyForCompatibility( + body: Record, +): Record { + const nextInput = normalizeResponsesInputForCompatibility(body.input); + if (nextInput === body.input) return body; + return { + ...body, + input: nextInput, + }; +} + +export function normalizeResponsesMessageContent(role: string, content: unknown): Array> { + const normalized = normalizeResponsesMessageItem({ + type: 'message', + role, + content, + }); + + if (isRecord(normalized) && Array.isArray(normalized.content)) { + return normalized.content.filter((item): item is Record => isRecord(item)); + } + + return []; +} + +const ALLOWED_RESPONSES_FIELDS = new Set([ + 'model', + 'input', + 'instructions', + 'max_output_tokens', + 'max_completion_tokens', + 'temperature', + 'top_p', + 'truncation', + 'tools', + 'tool_choice', + 'parallel_tool_calls', + 'metadata', + 'reasoning', + 'store', + 'safety_identifier', + 'stream', + 'stream_options', + 'user', + 'max_tool_calls', + 'prompt_cache_key', + 'prompt_cache_retention', + 'background', + 'previous_response_id', + 'text', + 'audio', + 'include', + 'response_format', + 'service_tier', + 'stop', + 'n', +]); + +export function sanitizeResponsesBodyForProxy( + body: Record, + modelName: string, + stream: boolean, +): Record { + let normalized = normalizeResponsesBodyForCompatibility({ + ...body, + model: modelName, + stream, + }); + + if (normalized.input === undefined) { + if (Array.isArray((normalized as Record).messages)) { + normalized = normalizeResponsesBodyForCompatibility( + convertOpenAiBodyToResponsesBody(normalized, modelName, stream), + ); + } else { + const prompt = asTrimmedString((normalized as Record).prompt); + if (prompt) { + normalized = { + ...normalized, + input: [toResponsesInputMessageFromText(prompt)], + }; + } + } + } + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(normalized)) { + if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue; + if (key === 'max_completion_tokens') continue; + sanitized[key] = value; + } + + const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens); + if (maxOutputTokens !== null && maxOutputTokens > 0) { + sanitized.max_output_tokens = Math.trunc(maxOutputTokens); + } else { + const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens); + if (maxCompletionTokens !== null && maxCompletionTokens > 0) { + sanitized.max_output_tokens = Math.trunc(maxCompletionTokens); + } + } + + sanitized.model = modelName; + sanitized.stream = stream; + return sanitized; +} + +export function convertOpenAiBodyToResponsesBody( + openaiBody: Record, + modelName: string, + stream: boolean, +): Record { + const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; + const systemContents: string[] = []; + const inputItems: Array> = []; + + for (const item of rawMessages) { + if (!isRecord(item)) continue; + const role = asTrimmedString(item.role).toLowerCase() || 'user'; + + if (role === 'system' || role === 'developer') { + const content = extractTextContent(item.content).trim(); + if (content) systemContents.push(content); + continue; + } + + if (role === 'assistant') { + const normalizedContent = normalizeResponsesMessageContent('assistant', item.content); + if (normalizedContent.length > 0) { + inputItems.push({ + type: 'message', + role: 'assistant', + content: normalizedContent, + }); + } + + const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; + for (let index = 0; index < rawToolCalls.length; index += 1) { + const toolCall = rawToolCalls[index]; + if (!isRecord(toolCall)) continue; + const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; + const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`; + const name = ( + asTrimmedString(functionPart.name) + || asTrimmedString(toolCall.name) + || `tool_${index}` + ); + const argumentsValue = normalizeOpenAiToolArguments( + functionPart.arguments ?? toolCall.arguments, + ); + + inputItems.push({ + type: 'function_call', + call_id: callId, + name, + arguments: argumentsValue, + }); + } + continue; + } + + if (role === 'tool') { + const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id); + if (!callId) continue; + inputItems.push({ + type: 'function_call_output', + call_id: callId, + output: normalizeToolOutput(item.content), + }); + continue; + } + + const normalizedContent = normalizeResponsesMessageContent('user', item.content); + if (normalizedContent.length <= 0) continue; + inputItems.push({ + type: 'message', + role: 'user', + content: normalizedContent, + }); + } + + const maxOutputTokens = ( + toFiniteNumber(openaiBody.max_output_tokens) + ?? toFiniteNumber(openaiBody.max_completion_tokens) + ?? toFiniteNumber(openaiBody.max_tokens) + ?? 4096 + ); + + const body: Record = { + model: modelName, + stream, + max_output_tokens: maxOutputTokens, + input: inputItems, + }; + + if (systemContents.length > 0) { + body.instructions = systemContents.join('\n\n'); + } + + const temperature = toFiniteNumber(openaiBody.temperature); + if (temperature !== null) body.temperature = temperature; + + const topP = toFiniteNumber(openaiBody.top_p); + if (topP !== null) body.top_p = topP; + + if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata; + if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning; + if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls; + if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools); + if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier; + if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls; + if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key; + if (openaiBody.prompt_cache_retention !== undefined) { + body.prompt_cache_retention = openaiBody.prompt_cache_retention; + } + if (openaiBody.background !== undefined) body.background = openaiBody.background; + if (openaiBody.user !== undefined) body.user = openaiBody.user; + if (openaiBody.include !== undefined) body.include = cloneJsonValue(openaiBody.include); + if (openaiBody.previous_response_id !== undefined) body.previous_response_id = openaiBody.previous_response_id; + if (openaiBody.truncation !== undefined) body.truncation = openaiBody.truncation; + if (openaiBody.service_tier !== undefined) body.service_tier = openaiBody.service_tier; + if (openaiBody.top_logprobs !== undefined) body.top_logprobs = openaiBody.top_logprobs; + if (openaiBody.stream_options !== undefined) body.stream_options = openaiBody.stream_options; + + const textConfig = cloneRecord(openaiBody.text) || {}; + const verbosity = ( + asTrimmedString(openaiBody.verbosity) + || asTrimmedString(isRecord(openaiBody.text) ? openaiBody.text.verbosity : undefined) + ); + if (verbosity) { + textConfig.verbosity = verbosity; + } + if (Object.keys(textConfig).length > 0) { + body.text = textConfig; + } + + const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice); + if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice; + + return normalizeResponsesBodyForCompatibility(body); +} + +type OpenAiToolCall = { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +}; + +function toOpenAiToolCall(item: Record, fallbackIndex: number): OpenAiToolCall | null { + const callId = ( + asTrimmedString(item.call_id) + || asTrimmedString(item.id) + || `call_${Date.now()}_${fallbackIndex}` + ); + const name = asTrimmedString(item.name); + if (!name) return null; + + return { + id: callId, + type: 'function', + function: { + name, + arguments: normalizeOpenAiToolArguments(item.arguments ?? item.input), + }, + }; +} + +function normalizeOpenAiContentBlock(item: Record): string | Record | null { + const type = asTrimmedString(item.type).toLowerCase(); + if (!type) { + const text = extractTextContent(item).trim(); + return text ? { type: 'text', text } : null; + } + + if ( + type === 'input_text' + || type === 'output_text' + || type === 'text' + || type === 'summary_text' + || type === 'reasoning_text' + ) { + const text = extractTextContent(item).trim(); + return text ? { type: 'text', text } : null; + } + + if (type === 'input_image') { + const imageUrl = item.image_url ?? item.url; + if (imageUrl === undefined) return null; + return { + type: 'image_url', + image_url: imageUrl, + }; + } + + if (type === 'input_audio' && item.input_audio !== undefined) { + return { + type: 'input_audio', + input_audio: item.input_audio, + }; + } + + if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') { + const text = extractTextContent(item).trim(); + return text ? { type: 'text', text } : null; + } + + return item; +} + +function toOpenAiMessageContent(content: unknown): string | Array> { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) { + if (isRecord(content)) { + const single = normalizeOpenAiContentBlock(content); + if (!single) return ''; + return typeof single === 'string' ? single : [single]; + } + return ''; + } + + const blocks = content + .map((item) => { + if (typeof item === 'string') return item.trim() ? item : null; + if (!isRecord(item)) return null; + return normalizeOpenAiContentBlock(item); + }) + .filter((item): item is string | Record => !!item); + + if (blocks.length === 1 && typeof blocks[0] === 'string') { + return blocks[0]; + } + return blocks; +} + +function convertResponsesToolsToOpenAi(rawTools: unknown): unknown { + if (!Array.isArray(rawTools)) return rawTools; + + return rawTools.map((item) => { + if (!isRecord(item)) return item; + const type = asTrimmedString(item.type).toLowerCase(); + + if (type === 'custom' || type === 'image_generation') return item; + if (type !== 'function') return item; + if (isRecord(item.function) && asTrimmedString(item.function.name)) return item; + + const name = asTrimmedString(item.name); + if (!name) return item; + + const fn: Record = { name }; + const description = asTrimmedString(item.description); + if (description) fn.description = description; + if (item.parameters !== undefined) fn.parameters = item.parameters; + if (item.strict !== undefined) fn.strict = item.strict; + + return { + type: 'function', + function: fn, + }; + }); +} + +function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown { + if (rawToolChoice === undefined) return undefined; + if (typeof rawToolChoice === 'string') return rawToolChoice; + if (!isRecord(rawToolChoice)) return rawToolChoice; + + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'function') { + if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) { + return rawToolChoice; + } + + const name = asTrimmedString(rawToolChoice.name); + if (!name) return 'required'; + return { + type: 'function', + function: { name }, + }; + } + + if (type === 'auto' || type === 'none' || type === 'required') { + return type; + } + + return rawToolChoice; +} + +export function convertResponsesBodyToOpenAiBody( + body: Record, + modelName: string, + stream: boolean, +): Record { + const messages: Array> = []; + const input = body.input; + let functionCallIndex = 0; + let pendingToolCalls: OpenAiToolCall[] = []; + + const flushPendingToolCalls = () => { + if (pendingToolCalls.length <= 0) return; + messages.push({ + role: 'assistant', + content: '', + tool_calls: pendingToolCalls, + }); + pendingToolCalls = []; + }; + + const pushToolOutputMessage = (callIdRaw: unknown, outputRaw: unknown) => { + const toolCallId = asTrimmedString(callIdRaw); + if (!toolCallId) return; + messages.push({ + role: 'tool', + tool_call_id: toolCallId, + content: normalizeToolOutput(outputRaw), + }); + }; + + const processInputItem = (item: unknown) => { + if (typeof item === 'string') { + flushPendingToolCalls(); + const text = item.trim(); + if (text) messages.push({ role: 'user', content: text }); + return; + } + + if (!isRecord(item)) return; + + const itemType = asTrimmedString(item.type).toLowerCase(); + if (itemType === 'function_call' || itemType === 'custom_tool_call') { + const toolCall = toOpenAiToolCall(item, functionCallIndex); + functionCallIndex += 1; + if (toolCall) pendingToolCalls.push(toolCall); + return; + } + + if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') { + flushPendingToolCalls(); + pushToolOutputMessage(item.call_id ?? item.id, item.output ?? item.content); + return; + } + + if (itemType === 'reasoning') { + flushPendingToolCalls(); + const reasoningContent = toOpenAiMessageContent(item.summary ?? item.content ?? item); + const hasReasoningContent = typeof reasoningContent === 'string' + ? reasoningContent.trim().length > 0 + : Array.isArray(reasoningContent) && reasoningContent.length > 0; + if (!hasReasoningContent) return; + + messages.push({ + role: 'assistant', + content: reasoningContent, + }); + return; + } + + flushPendingToolCalls(); + const role = asTrimmedString(item.role).toLowerCase() || 'user'; + const normalizedRole = role === 'developer' ? 'system' : role; + const content = toOpenAiMessageContent(item.content ?? item.input ?? item); + + if (normalizedRole === 'tool') { + pushToolOutputMessage(item.tool_call_id ?? item.call_id ?? item.id, item.content); + return; + } + + const hasContent = typeof content === 'string' + ? content.trim().length > 0 + : Array.isArray(content) && content.length > 0; + if (!hasContent) return; + + messages.push({ + role: normalizedRole, + content, + }); + }; + + if (typeof input === 'string') { + const text = input.trim(); + if (text) messages.push({ role: 'user', content: text }); + } else if (Array.isArray(input)) { + for (const item of input) processInputItem(item); + } else if (isRecord(input)) { + processInputItem(input); + } + flushPendingToolCalls(); + + const instructions = asTrimmedString(body.instructions); + if (instructions) { + messages.unshift({ role: 'system', content: instructions }); + } + + const payload: Record = { + model: modelName, + stream, + messages, + }; + + if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) { + payload.temperature = body.temperature; + } + if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) { + payload.top_p = body.top_p; + } + if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) { + payload.max_tokens = body.max_output_tokens; + } + if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls; + if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools); + if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice); + if (body.safety_identifier !== undefined) payload.safety_identifier = body.safety_identifier; + if (body.max_tool_calls !== undefined) payload.max_tool_calls = body.max_tool_calls; + if (body.prompt_cache_key !== undefined) payload.prompt_cache_key = body.prompt_cache_key; + if (body.prompt_cache_retention !== undefined) payload.prompt_cache_retention = body.prompt_cache_retention; + if (body.background !== undefined) payload.background = body.background; + if (body.user !== undefined) payload.user = body.user; + if (body.include !== undefined) payload.include = cloneJsonValue(body.include); + if (body.previous_response_id !== undefined) payload.previous_response_id = body.previous_response_id; + if (body.truncation !== undefined) payload.truncation = body.truncation; + if (body.service_tier !== undefined) payload.service_tier = body.service_tier; + if (body.top_logprobs !== undefined) payload.top_logprobs = body.top_logprobs; + if (body.stream_options !== undefined) payload.stream_options = body.stream_options; + if (isRecord(body.text) && asTrimmedString(body.text.verbosity)) { + payload.verbosity = asTrimmedString(body.text.verbosity); + } + + return payload; +} diff --git a/src/server/transformers/openai/responses/inbound.ts b/src/server/transformers/openai/responses/inbound.ts new file mode 100644 index 0000000..153524c --- /dev/null +++ b/src/server/transformers/openai/responses/inbound.ts @@ -0,0 +1,6 @@ +import { normalizeResponsesInputForCompatibility, normalizeResponsesMessageItem } from './compatibility.js'; + +export const openAiResponsesInbound = { + normalizeInput: normalizeResponsesInputForCompatibility, + normalizeMessage: normalizeResponsesMessageItem, +}; diff --git a/src/server/transformers/openai/responses/index.ts b/src/server/transformers/openai/responses/index.ts new file mode 100644 index 0000000..7c3209f --- /dev/null +++ b/src/server/transformers/openai/responses/index.ts @@ -0,0 +1,66 @@ +import { type StreamTransformContext } from '../../shared/normalized.js'; +import { + convertOpenAiBodyToResponsesBody, + convertResponsesBodyToOpenAiBody, + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContent, + sanitizeResponsesBodyForProxy, +} from './conversion.js'; +import { normalizeResponsesMessageItem } from './compatibility.js'; +import { + type OpenAiResponsesAggregateState, + completeResponsesStream, + createOpenAiResponsesAggregateState, + failResponsesStream, + serializeConvertedResponsesEvents, +} from './aggregator.js'; +import { openAiResponsesOutbound } from './outbound.js'; +import { openAiResponsesStream } from './stream.js'; +import { openAiResponsesUsage } from './usage.js'; + +export const openAiResponsesTransformer = { + protocol: 'openai/responses' as const, + inbound: { + normalizeInput: normalizeResponsesInputForCompatibility, + normalizeMessage: normalizeResponsesMessageItem, + normalizeContent: normalizeResponsesMessageContent, + sanitizeProxyBody: sanitizeResponsesBodyForProxy, + fromOpenAiBody: convertOpenAiBodyToResponsesBody, + toOpenAiBody: convertResponsesBodyToOpenAiBody, + }, + outbound: openAiResponsesOutbound, + stream: openAiResponsesStream, + usage: openAiResponsesUsage, + aggregator: { + createState: createOpenAiResponsesAggregateState, + serialize: serializeConvertedResponsesEvents, + complete: completeResponsesStream, + fail: failResponsesStream, + }, + transformRequest(body: unknown) { + return body; + }, + createStreamContext(modelName: string): StreamTransformContext { + return openAiResponsesStream.createContext(modelName); + }, + transformFinalResponse(payload: unknown, modelName: string, fallbackText = '') { + return openAiResponsesOutbound.normalizeFinal(payload, modelName, fallbackText); + }, + transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string) { + return openAiResponsesStream.normalizeEvent(payload, context, modelName); + }, + pullSseEvents(buffer: string) { + return openAiResponsesStream.pullSseEvents(buffer); + }, +}; + +export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer; +export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState; +export { + convertOpenAiBodyToResponsesBody, + convertResponsesBodyToOpenAiBody, + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContent, + normalizeResponsesMessageItem, + sanitizeResponsesBodyForProxy, +}; diff --git a/src/server/transformers/openai/responses/outbound.ts b/src/server/transformers/openai/responses/outbound.ts new file mode 100644 index 0000000..f690dec --- /dev/null +++ b/src/server/transformers/openai/responses/outbound.ts @@ -0,0 +1,182 @@ +import { + normalizeUpstreamFinalResponse, + type NormalizedFinalResponse, +} from '../../shared/normalized.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function ensureResponseId(rawId: string): string { + const trimmed = rawId.trim() || `resp_${Date.now()}`; + return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`; +} + +function ensureMessageId(rawId: string): string { + const trimmed = rawId.trim() || `msg_${Date.now()}`; + return trimmed.startsWith('msg_') ? trimmed : `msg_${trimmed}`; +} + +function ensureFunctionCallId(rawId: string): string { + const trimmed = rawId.trim(); + if (!trimmed) return `call_${Date.now()}`; + return trimmed.startsWith('call_') ? trimmed : `call_${trimmed}`; +} + +function stringifyToolInput(input: unknown): string { + if (typeof input === 'string') return input; + if (!input || typeof input !== 'object') return ''; + try { + return JSON.stringify(input); + } catch { + return ''; + } +} + +export type ResponsesUsageSummary = { + promptTokens: number; + completionTokens: number; + totalTokens: number; +}; + +export type ResponsesToolCall = { + id: string; + name: string; + arguments: string; +}; + +function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] { + if (!isRecord(payload)) return []; + + if (Array.isArray(payload.choices)) { + const choice = payload.choices[0]; + const message = isRecord((choice as any)?.message) ? (choice as any).message : {}; + const toolCalls = Array.isArray((message as any).tool_calls) ? (message as any).tool_calls : []; + return toolCalls + .map((item: unknown, index: number) => { + if (!isRecord(item)) return null; + const fn = isRecord(item.function) ? item.function : {}; + const id = typeof item.id === 'string' && item.id.trim().length > 0 + ? item.id + : `call_${index}`; + const name = typeof fn.name === 'string' ? fn.name : ''; + const args = typeof fn.arguments === 'string' ? fn.arguments : ''; + return { + id: ensureFunctionCallId(id), + name, + arguments: args, + }; + }) + .filter((item): item is ResponsesToolCall => !!item); + } + + if (payload.type === 'message' && Array.isArray(payload.content)) { + return payload.content + .map((item: unknown, index: number) => { + if (!isRecord(item) || item.type !== 'tool_use') return null; + const id = typeof item.id === 'string' && item.id.trim().length > 0 + ? item.id + : `call_${index}`; + const name = typeof item.name === 'string' ? item.name : ''; + const args = stringifyToolInput(item.input); + return { + id: ensureFunctionCallId(id), + name, + arguments: args, + }; + }) + .filter((item): item is ResponsesToolCall => !!item); + } + + return []; +} + +export function normalizeResponsesFinalPayload( + payload: unknown, + modelName: string, + fallbackText = '', +): NormalizedFinalResponse { + return normalizeUpstreamFinalResponse(payload, modelName, fallbackText); +} + +export function serializeResponsesFinalPayload(input: { + upstreamPayload: unknown; + normalized: NormalizedFinalResponse; + usage: ResponsesUsageSummary; +}): Record { + const { upstreamPayload, normalized, usage } = input; + if (isRecord(upstreamPayload) && upstreamPayload.object === 'response') { + return upstreamPayload; + } + + const normalizedId = typeof normalized.id === 'string' && normalized.id.trim() + ? normalized.id.trim() + : `resp_${Date.now()}`; + const responseId = ensureResponseId(normalizedId); + const messageId = ensureMessageId(normalizedId); + const toolCalls = extractToolCallsFromUpstream(upstreamPayload); + + const output: Array> = []; + if (normalized.reasoningContent) { + output.push({ + id: ensureMessageId(`${normalizedId}_reasoning`), + type: 'reasoning', + status: 'completed', + summary: [{ + type: 'summary_text', + text: normalized.reasoningContent, + }], + }); + } + if (normalized.content || toolCalls.length === 0) { + output.push({ + id: messageId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ + type: 'output_text', + text: normalized.content || '', + }], + }); + } + + for (const toolCall of toolCalls) { + output.push({ + id: toolCall.id, + type: 'function_call', + status: 'completed', + call_id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + }); + } + + return { + id: responseId, + object: 'response', + created: normalized.created, + status: 'completed', + model: normalized.model, + output, + output_text: normalized.content || '', + usage: { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + }; +} + +export const openAiResponsesOutbound = { + normalizeFinal: normalizeResponsesFinalPayload, + serializeFinal: serializeResponsesFinalPayload, +}; + +export { + serializeResponsesFinalPayload as toResponsesPayload, +}; diff --git a/src/server/transformers/openai/responses/stream.ts b/src/server/transformers/openai/responses/stream.ts new file mode 100644 index 0000000..7d4da3e --- /dev/null +++ b/src/server/transformers/openai/responses/stream.ts @@ -0,0 +1,80 @@ +import { + createStreamTransformContext, + normalizeUpstreamStreamEvent, + pullSseEventsWithDone, + type NormalizedStreamEvent, + type StreamTransformContext, +} from '../../shared/normalized.js'; +import { + completeResponsesStream, + createOpenAiResponsesAggregateState, + failResponsesStream, + serializeConvertedResponsesEvents, + type OpenAiResponsesAggregateState, +} from './aggregator.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +export type OpenAiResponsesStreamEvent = NormalizedStreamEvent & { + responsesEventType?: string; + responsesPayload?: Record; + usagePayload?: Record; +}; + +export const openAiResponsesStream = { + eventNames: [ + 'response.created', + 'response.in_progress', + 'response.output_item.added', + 'response.output_item.done', + 'response.content_part.added', + 'response.content_part.done', + 'response.output_text.delta', + 'response.output_text.done', + 'response.function_call_arguments.delta', + 'response.function_call_arguments.done', + 'response.custom_tool_call_input.delta', + 'response.custom_tool_call_input.done', + 'response.reasoning_summary_part.added', + 'response.reasoning_summary_part.done', + 'response.reasoning_summary_text.delta', + 'response.reasoning_summary_text.done', + 'response.image_generation_call.generating', + 'response.image_generation_call.in_progress', + 'response.image_generation_call.partial_image', + 'response.image_generation_call.completed', + 'response.completed', + 'response.failed', + ] as const, + createContext(modelName: string): StreamTransformContext { + return createStreamTransformContext(modelName); + }, + normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiResponsesStreamEvent { + const normalized = normalizeUpstreamStreamEvent(payload, context, modelName) as OpenAiResponsesStreamEvent; + if (isRecord(payload) && typeof payload.type === 'string' && payload.type.startsWith('response.')) { + normalized.responsesEventType = payload.type; + normalized.responsesPayload = payload; + } + if (isRecord(payload) && isRecord(payload.usage)) { + normalized.usagePayload = payload.usage; + } + return normalized; + }, + pullSseEvents(buffer: string) { + return pullSseEventsWithDone(buffer); + }, +}; + +export type ResponsesStreamState = OpenAiResponsesAggregateState; + +export function createResponsesStreamState(modelName: string): ResponsesStreamState { + return createOpenAiResponsesAggregateState(modelName); +} + +export { + completeResponsesStream, + failResponsesStream, + serializeConvertedResponsesEvents, +}; diff --git a/src/server/transformers/openai/responses/usage.ts b/src/server/transformers/openai/responses/usage.ts new file mode 100644 index 0000000..a4ad2ec --- /dev/null +++ b/src/server/transformers/openai/responses/usage.ts @@ -0,0 +1,6 @@ +import { createEmptyNormalizedUsage, mergeNormalizedUsage } from '../../shared/normalized.js'; + +export const openAiResponsesUsage = { + empty: createEmptyNormalizedUsage, + merge: mergeNormalizedUsage, +}; diff --git a/src/server/transformers/shared/chatFormatsCore.ts b/src/server/transformers/shared/chatFormatsCore.ts new file mode 100644 index 0000000..61b22b8 --- /dev/null +++ b/src/server/transformers/shared/chatFormatsCore.ts @@ -0,0 +1,1541 @@ +export type DownstreamFormat = 'openai' | 'claude'; + +export type ParsedSseEvent = { + event: string; + data: string; +}; + +export type StreamTransformContext = { + id: string; + model: string; + created: number; + roleSent: boolean; + doneSent: boolean; + toolCalls: Record; +}; + +export type ClaudeDownstreamContext = { + messageStarted: boolean; + contentBlockStarted: boolean; + doneSent: boolean; + textBlockIndex: number | null; + nextContentBlockIndex: number; + toolBlocks: Record; +}; + +export type NormalizedStreamEvent = { + role?: 'assistant'; + contentDelta?: string; + reasoningDelta?: string; + toolCallDeltas?: Array<{ + index: number; + id?: string; + name?: string; + argumentsDelta?: string; + }>; + finishReason?: string | null; + done?: boolean; +}; + +export type NormalizedFinalResponse = { + id: string; + model: string; + created: number; + content: string; + reasoningContent: string; + finishReason: string; + toolCalls: Array<{ + id: string; + name: string; + arguments: string; + }>; +}; + +export type ParsedDownstreamChatRequest = { + requestedModel: string; + isStream: boolean; + upstreamBody: Record; + claudeOriginalBody?: Record; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function pickFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function ensureIntegerTimestamp(value: unknown, fallback: number): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.trunc(n); +} + +function joinNonEmpty(parts: string[]): string { + return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n'); +} + +function textFromPart(part: unknown): string { + if (typeof part === 'string') return part; + if (!isRecord(part)) return ''; + + if (typeof part.text === 'string') return part.text; + if (typeof part.content === 'string') return part.content; + if (typeof part.output_text === 'string') return part.output_text; + if (typeof part.completion === 'string') return part.completion; + if (typeof part.partial_json === 'string') return part.partial_json; + if (typeof part.reasoning_content === 'string') return part.reasoning_content; + if (typeof part.reasoning === 'string') return part.reasoning; + + if (Array.isArray(part.content)) { + return part.content.map((item) => textFromPart(item)).join(''); + } + + if (isRecord(part.delta)) { + const fromDelta = textFromPart(part.delta); + if (fromDelta) return fromDelta; + } + + return ''; +} + +function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } { + if (typeof value === 'string') return { content: value, reasoning: '' }; + if (Array.isArray(value)) { + const contentParts: string[] = []; + const reasoningParts: string[] = []; + for (const item of value) { + if (typeof item === 'string') { + contentParts.push(item); + continue; + } + if (!isRecord(item)) continue; + const type = typeof item.type === 'string' ? item.type : ''; + + if (type === 'thinking' && typeof item.thinking === 'string') { + reasoningParts.push(item.thinking); + continue; + } + if (type === 'thinking_delta' && typeof item.text === 'string') { + reasoningParts.push(item.text); + continue; + } + if (typeof item.thought === 'boolean' && item.thought && typeof item.text === 'string') { + reasoningParts.push(item.text); + continue; + } + + const text = textFromPart(item); + if (text) contentParts.push(text); + } + + return { + content: contentParts.join(''), + reasoning: reasoningParts.join(''), + }; + } + + if (!isRecord(value)) return { content: '', reasoning: '' }; + + if (Array.isArray(value.parts)) { + return extractTextAndReasoning(value.parts); + } + + return { + content: textFromPart(value), + reasoning: '', + }; +} + +export function normalizeStopReason(raw: unknown): string | null { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (!value) return null; + + if (value === 'failed' || value === 'error') { + return 'error'; + } + + if ( + value === 'end_turn' + || value === 'stop' + || value === 'end' + || value === 'eos' + || value === 'finished' + || value === 'completed' + || value === 'stop_sequence' + ) { + return 'stop'; + } + + if ( + value === 'incomplete' + || value === 'max_tokens' + || value === 'length' + || value === 'max_output_tokens' + || value === 'max_tokens_exceeded' + || value.includes('max') + ) { + return 'length'; + } + + if (value === 'tool_use' || value === 'tool_calls' || value.includes('tool')) { + return 'tool_calls'; + } + + return null; +} + +export function toClaudeStopReason(finishReason: string | null | undefined): string { + const value = normalizeStopReason(finishReason); + if (value === 'length') return 'max_tokens'; + if (value === 'tool_calls') return 'tool_use'; + return 'end_turn'; +} + +export function createStreamTransformContext(modelName: string): StreamTransformContext { + return { + id: `chatcmpl-meta-${Date.now()}`, + model: modelName, + created: Math.floor(Date.now() / 1000), + roleSent: false, + doneSent: false, + toolCalls: {}, + }; +} + +export function createClaudeDownstreamContext(): ClaudeDownstreamContext { + return { + messageStarted: false, + contentBlockStarted: false, + doneSent: false, + textBlockIndex: null, + nextContentBlockIndex: 0, + toolBlocks: {}, + }; +} + +function buildClaudeMessageId(sourceId: string): string { + if (sourceId.startsWith('msg_')) return sourceId; + const sanitized = sourceId.replace(/[^A-Za-z0-9_-]/g, '_'); + return `msg_${sanitized || Date.now()}`; +} + +function serializeSse(event: string, data: unknown): string { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + if (event) { + return `event: ${event}\ndata: ${payload}\n\n`; + } + return `data: ${payload}\n\n`; +} + +function extractAssistantContent(choice: any): string { + const messageContent = choice?.message?.content; + const parsedMessage = extractTextAndReasoning(messageContent).content; + if (parsedMessage) return parsedMessage; + + const content = extractTextAndReasoning(choice?.content).content; + if (content) return content; + + if (typeof choice?.text === 'string' && choice.text.length > 0) return choice.text; + if (typeof choice?.completion === 'string' && choice.completion.length > 0) return choice.completion; + if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) return choice.output_text; + if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) return choice.delta.content; + + return ''; +} + +function extractAssistantReasoning(choice: any): string { + const message = choice?.message || {}; + const direct = [ + message.reasoning_content, + message.reasoning, + choice?.reasoning_content, + choice?.reasoning, + ].find((item) => typeof item === 'string' && item.length > 0); + + if (typeof direct === 'string') return direct; + + const parsed = extractTextAndReasoning(message.content); + if (parsed.reasoning) return parsed.reasoning; + + const nested = extractTextAndReasoning(choice?.content).reasoning; + if (nested) return nested; + + return ''; +} + +function parseClaudeMessageContent(content: unknown): string { + return extractTextAndReasoning(content).content; +} + +function parseResponsesOutputText(payload: Record): string { + const direct = typeof payload.output_text === 'string' ? payload.output_text : ''; + if (direct) return direct; + + const output = Array.isArray(payload.output) ? payload.output : []; + const parts: string[] = []; + for (const item of output) { + if (!isRecord(item)) continue; + const parsed = extractTextAndReasoning(item.content ?? item); + if (parsed.content) parts.push(parsed.content); + } + + return parts.join('\n\n'); +} + +function stringifyUnknownValue(value: unknown): string { + if (typeof value === 'string') return value; + if (value === undefined || value === null) return ''; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value) || isRecord(value)) { + try { + return JSON.stringify(value); + } catch { + return ''; + } + } + return ''; +} + +function parseJsonLike(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { value }; + } +} + +function collectToolCallsFromOpenAiChoice(choice: any): Array<{ id: string; name: string; arguments: string }> { + const message = isRecord(choice?.message) ? choice.message : {}; + const rawToolCalls = Array.isArray((message as any).tool_calls) + ? (message as any).tool_calls + : (Array.isArray(choice?.tool_calls) ? choice.tool_calls : []); + + const toolCalls: Array<{ id: string; name: string; arguments: string }> = []; + for (let index = 0; index < rawToolCalls.length; index += 1) { + const rawToolCall = rawToolCalls[index]; + if (!isRecord(rawToolCall)) continue; + const fn = isRecord(rawToolCall.function) ? rawToolCall.function : {}; + const id = ( + typeof rawToolCall.id === 'string' && rawToolCall.id.trim().length > 0 + ? rawToolCall.id.trim() + : `call_${index}` + ); + const name = ( + typeof fn.name === 'string' && fn.name.trim().length > 0 + ? fn.name.trim() + : (typeof rawToolCall.name === 'string' ? rawToolCall.name.trim() : '') + ); + const argumentsText = ( + typeof fn.arguments === 'string' + ? fn.arguments + : stringifyUnknownValue(fn.arguments ?? rawToolCall.arguments) + ); + toolCalls.push({ + id, + name, + arguments: argumentsText, + }); + } + + return toolCalls; +} + +function collectToolCallsFromClaudeContent(content: unknown): Array<{ id: string; name: string; arguments: string }> { + const contentItems = Array.isArray(content) ? content : []; + const toolCalls: Array<{ id: string; name: string; arguments: string }> = []; + + for (let index = 0; index < contentItems.length; index += 1) { + const block = contentItems[index]; + if (!isRecord(block)) continue; + if (block.type !== 'tool_use') continue; + + const id = ( + typeof block.id === 'string' && block.id.trim().length > 0 + ? block.id.trim() + : `toolu_${index}` + ); + const name = typeof block.name === 'string' ? block.name.trim() : ''; + const argumentsText = stringifyUnknownValue(block.input); + toolCalls.push({ + id, + name, + arguments: argumentsText, + }); + } + + return toolCalls; +} + +function collectToolCallsFromResponsesPayload(payload: Record): Array<{ id: string; name: string; arguments: string }> { + const output = Array.isArray(payload.output) ? payload.output : []; + const toolCalls: Array<{ id: string; name: string; arguments: string }> = []; + + for (let index = 0; index < output.length; index += 1) { + const item = output[index]; + if (!isRecord(item)) continue; + if (item.type !== 'function_call') continue; + + const id = ( + typeof item.call_id === 'string' && item.call_id.trim().length > 0 + ? item.call_id.trim() + : (typeof item.id === 'string' && item.id.trim().length > 0 ? item.id.trim() : `call_${index}`) + ); + const name = typeof item.name === 'string' ? item.name.trim() : ''; + const argumentsText = ( + typeof item.arguments === 'string' + ? item.arguments + : stringifyUnknownValue(item.arguments) + ); + toolCalls.push({ + id, + name, + arguments: argumentsText, + }); + } + + return toolCalls; +} + +function convertClaudeRequestToOpenAiBody(body: Record): { + model: string; + stream: boolean; + messages: Array>; + payload: Record; +} { + const model = typeof body.model === 'string' ? body.model.trim() : ''; + const stream = body.stream === true; + + const messages: Array> = []; + + const appendMessage = (role: string, content: unknown) => { + const text = parseClaudeMessageContent(content); + if (!text) return; + messages.push({ role, content: text }); + }; + + const appendToolResultMessage = (toolUseId: unknown, content: unknown) => { + const toolCallId = typeof toolUseId === 'string' ? toolUseId.trim() : ''; + if (!toolCallId) return; + + const text = ( + parseClaudeMessageContent(content) + || stringifyUnknownValue(content) + ).trim(); + if (!text) return; + + messages.push({ + role: 'tool', + tool_call_id: toolCallId, + content: text, + }); + }; + + const system = body.system; + if (typeof system === 'string') { + appendMessage('system', system); + } else if (Array.isArray(system)) { + const merged = system.map((item) => parseClaudeMessageContent(item)).filter((item) => item.length > 0).join('\n\n'); + if (merged) appendMessage('system', merged); + } + + const rawMessages = Array.isArray(body.messages) ? body.messages : []; + for (const message of rawMessages) { + if (!isRecord(message)) continue; + const role = typeof message.role === 'string' ? message.role : 'user'; + const mappedRole = role === 'assistant' || role === 'system' ? role : 'user'; + const content = message.content; + + if (!Array.isArray(content)) { + appendMessage(mappedRole, content); + continue; + } + + // Claude tool blocks need explicit OpenAI mapping: + // - assistant.tool_use -> assistant.tool_calls + // - user.tool_result -> tool messages with tool_call_id + const textParts: string[] = []; + const toolCalls: Array> = []; + + const flushTextAsMessage = () => { + const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n'); + textParts.length = 0; + if (!merged) return; + messages.push({ role: mappedRole, content: merged }); + }; + + for (const block of content) { + if (!isRecord(block)) continue; + const blockType = typeof block.type === 'string' ? block.type : ''; + + if (blockType === 'tool_result') { + flushTextAsMessage(); + appendToolResultMessage(block.tool_use_id, block.content); + continue; + } + + if (mappedRole === 'assistant' && blockType === 'tool_use') { + const id = typeof block.id === 'string' && block.id.trim().length > 0 + ? block.id.trim() + : `call_${toolCalls.length}`; + const name = typeof block.name === 'string' ? block.name.trim() : ''; + const rawInput = block.input; + const args = ( + typeof rawInput === 'string' + ? rawInput + : stringifyUnknownValue(rawInput) || '{}' + ); + + const fn: Record = { arguments: args }; + if (name) fn.name = name; + + toolCalls.push({ + id, + type: 'function', + function: fn, + }); + continue; + } + + const text = parseClaudeMessageContent(block); + if (text) textParts.push(text); + } + + const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n'); + if (toolCalls.length > 0) { + const assistantMessage: Record = { + role: 'assistant', + tool_calls: toolCalls, + }; + // Keep textual assistant preface when present. + assistantMessage.content = merged || ''; + messages.push(assistantMessage); + } else if (merged) { + messages.push({ + role: mappedRole, + content: merged, + }); + } + } + + const payload: Record = { + model, + stream, + messages, + }; + + const temperature = pickFiniteNumber(body.temperature); + if (temperature !== undefined) payload.temperature = temperature; + + const topP = pickFiniteNumber(body.top_p); + if (topP !== undefined) payload.top_p = topP; + + const maxTokens = pickFiniteNumber(body.max_tokens); + if (maxTokens !== undefined) { + payload.max_tokens = maxTokens; + } else { + payload.max_tokens = 4096; + } + + if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) { + payload.stop = body.stop_sequences; + } + + if (body.tools !== undefined) payload.tools = body.tools; + if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice; + + return { model, stream, messages, payload }; +} + +export function parseDownstreamChatRequest( + body: unknown, + format: DownstreamFormat, +): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } { + const raw = isRecord(body) ? body : {}; + + if (format === 'claude') { + const converted = convertClaudeRequestToOpenAiBody(raw); + if (!converted.model) { + return { + error: { + statusCode: 400, + payload: { error: { message: 'model is required', type: 'invalid_request_error' } }, + }, + }; + } + + if (converted.messages.length <= 0) { + return { + error: { + statusCode: 400, + payload: { error: { message: 'messages is required', type: 'invalid_request_error' } }, + }, + }; + } + + return { + value: { + requestedModel: converted.model, + isStream: converted.stream, + upstreamBody: converted.payload, + claudeOriginalBody: raw, + }, + }; + } + + const model = typeof raw.model === 'string' ? raw.model.trim() : ''; + if (!model) { + return { + error: { + statusCode: 400, + payload: { error: { message: 'model is required', type: 'invalid_request_error' } }, + }, + }; + } + + const hasMessages = Array.isArray(raw.messages) && raw.messages.length > 0; + if (!hasMessages) { + const hint = raw.input !== undefined + ? 'messages is required for /v1/chat/completions. For Responses payload, use /v1/responses.' + : 'messages is required'; + return { + error: { + statusCode: 400, + payload: { error: { message: hint, type: 'invalid_request_error' } }, + }, + }; + } + + return { + value: { + requestedModel: model, + isStream: raw.stream === true, + upstreamBody: raw, + }, + }; +} + +export function normalizeUpstreamFinalResponse( + payload: unknown, + fallbackModel: string, + fallbackText = '', +): NormalizedFinalResponse { + const now = Math.floor(Date.now() / 1000); + const fallbackId = `chatcmpl-meta-${Date.now()}`; + + if (isRecord(payload) && Array.isArray(payload.choices)) { + const choice = payload.choices[0] ?? {}; + const content = extractAssistantContent(choice) || extractAssistantContent(payload); + const reasoning = extractAssistantReasoning(choice) || extractAssistantReasoning(payload); + const toolCalls = collectToolCallsFromOpenAiChoice(choice); + return { + id: isNonEmptyString(payload.id) ? payload.id : fallbackId, + model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, + created: ensureIntegerTimestamp(payload.created, now), + content: content || (toolCalls.length > 0 ? '' : fallbackText), + reasoningContent: reasoning, + finishReason: toolCalls.length > 0 + ? 'tool_calls' + : (normalizeStopReason(choice?.finish_reason ?? payload.stop_reason) || 'stop'), + toolCalls, + }; + } + + if (isRecord(payload) && typeof payload.type === 'string' && payload.type === 'message') { + const toolCalls = collectToolCallsFromClaudeContent(payload.content); + return { + id: isNonEmptyString(payload.id) ? payload.id : fallbackId, + model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, + created: now, + content: parseClaudeMessageContent(payload.content) || (toolCalls.length > 0 ? '' : fallbackText), + reasoningContent: extractTextAndReasoning(payload.content).reasoning, + finishReason: toolCalls.length > 0 ? 'tool_calls' : (normalizeStopReason(payload.stop_reason) || 'stop'), + toolCalls, + }; + } + + if (isRecord(payload) && ((payload as any).object === 'response' || Array.isArray((payload as any).output))) { + const toolCalls = collectToolCallsFromResponsesPayload(payload); + return { + id: isNonEmptyString(payload.id) ? payload.id : fallbackId, + model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, + created: ensureIntegerTimestamp(payload.created, now), + content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText), + reasoningContent: '', + finishReason: toolCalls.length > 0 + ? 'tool_calls' + : (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'), + toolCalls, + }; + } + + if (isRecord(payload) && Array.isArray(payload.candidates)) { + const candidate = payload.candidates[0] || {}; + const parsedCandidate = extractTextAndReasoning(candidate?.content?.parts || candidate?.content); + return { + id: isNonEmptyString((payload as any).responseId) ? (payload as any).responseId : fallbackId, + model: isNonEmptyString((payload as any).modelVersion) + ? (payload as any).modelVersion + : fallbackModel, + created: now, + content: parsedCandidate.content || fallbackText, + reasoningContent: parsedCandidate.reasoning, + finishReason: normalizeStopReason(candidate?.finishReason || (payload as any).finishReason) || 'stop', + toolCalls: [], + }; + } + + if (typeof payload === 'string' && payload.trim()) { + return { + id: fallbackId, + model: fallbackModel, + created: now, + content: payload, + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }; + } + + return { + id: fallbackId, + model: fallbackModel, + created: now, + content: fallbackText, + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }; +} + +export function normalizeUpstreamStreamEvent( + payload: unknown, + context: StreamTransformContext, + fallbackModel: string, +): NormalizedStreamEvent { + if (!isRecord(payload)) return {}; + + if (Array.isArray(payload.choices)) { + if (isNonEmptyString(payload.id)) context.id = payload.id; + if (isNonEmptyString(payload.model)) context.model = payload.model; + context.created = ensureIntegerTimestamp(payload.created, context.created); + + const choice = payload.choices[0] ?? {}; + const delta = isRecord(choice?.delta) ? choice.delta : {}; + const deltaParsed = extractTextAndReasoning(delta.content ?? delta); + + const rawContentDelta = + deltaParsed.content + || (typeof choice?.message?.content === 'string' ? choice.message.content : '') + || ''; + + const reasoningDelta = + (typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '') + || (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '') + || deltaParsed.reasoning + || ''; + + // Some upstream providers (e.g. certain OpenAI-compatible aggregators) emit thinking + // tokens with the same text duplicated in both delta.content and delta.reasoning_content. + // When the two values are identical it means content is just echoing the reasoning — + // suppress it so internal thinking is never leaked to downstream consumers. + const contentDelta = (reasoningDelta && rawContentDelta === reasoningDelta) + ? '' + : rawContentDelta; + + const rawToolCalls = Array.isArray((delta as any).tool_calls) + ? ((delta as any).tool_calls as unknown[]) + : []; + const toolCallDeltas = rawToolCalls + .map((item, itemIndex) => { + if (!isRecord(item)) return null; + const functionPart = isRecord(item.function) ? item.function : {}; + const index = ( + typeof item.index === 'number' && Number.isFinite(item.index) + ? Math.max(0, Math.trunc(item.index)) + : itemIndex + ); + const id = typeof item.id === 'string' && item.id.trim().length > 0 + ? item.id + : undefined; + const name = typeof functionPart.name === 'string' && functionPart.name.trim().length > 0 + ? functionPart.name + : undefined; + const argumentsDelta = typeof functionPart.arguments === 'string' + ? functionPart.arguments + : undefined; + + if (!id && !name && argumentsDelta === undefined) return null; + return { + index, + id, + name, + argumentsDelta, + }; + }) + .filter((item): item is NonNullable => !!item); + + return { + role: (delta as any).role === 'assistant' ? 'assistant' : undefined, + contentDelta: contentDelta || undefined, + reasoningDelta: reasoningDelta || undefined, + toolCallDeltas: toolCallDeltas.length > 0 ? toolCallDeltas : undefined, + finishReason: normalizeStopReason(choice?.finish_reason), + }; + } + + const type = typeof payload.type === 'string' ? payload.type : ''; + if (type.startsWith('response.output_text')) { + const deltaText = typeof payload.delta === 'string' + ? payload.delta + : extractTextAndReasoning(payload.delta).content; + return { + contentDelta: deltaText || undefined, + }; + } + + if (type === 'response.reasoning_summary_text.delta') { + const deltaText = typeof payload.delta === 'string' + ? payload.delta + : extractTextAndReasoning(payload.delta).content; + return { + reasoningDelta: deltaText || undefined, + }; + } + + if (type === 'response.failed' || type === 'response.incomplete' || type === 'error') { + const responsePayload = isRecord((payload as any).response) ? (payload as any).response : null; + const finishReason = normalizeStopReason( + (responsePayload as any)?.status + ?? (payload as any).status + ?? (payload as any).type, + ) || 'error'; + return { + finishReason, + done: true, + }; + } + + if (type === 'response.output_item.added' && isRecord((payload as any).item)) { + const item = (payload as any).item as Record; + if (item.type === 'function_call') { + const outputIndex = ( + typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index) + ? Math.max(0, Math.trunc((payload as any).output_index)) + : 0 + ); + const toolCallId = ( + isNonEmptyString(item.call_id) ? item.call_id + : (isNonEmptyString(item.id) ? item.id : undefined) + ); + const toolName = isNonEmptyString(item.name) ? item.name : undefined; + return { + toolCallDeltas: [{ + index: outputIndex, + id: toolCallId, + name: toolName, + }], + }; + } + } + + if (type === 'response.function_call_arguments.delta' || type === 'response.function_call_arguments.done') { + const outputIndex = ( + typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index) + ? Math.max(0, Math.trunc((payload as any).output_index)) + : 0 + ); + const toolCallId = ( + isNonEmptyString((payload as any).call_id) ? (payload as any).call_id + : (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined) + ); + const toolName = isNonEmptyString((payload as any).name) ? (payload as any).name : undefined; + const rawArguments = ( + type === 'response.function_call_arguments.done' + ? (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined) + : ( + typeof payload.delta === 'string' + ? payload.delta + : (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined) + ) + ); + let argumentsDelta = rawArguments; + if (type === 'response.function_call_arguments.done' && typeof rawArguments === 'string') { + const existingArguments = context.toolCalls[outputIndex]?.arguments || ''; + if (existingArguments && rawArguments.startsWith(existingArguments)) { + const missingSuffix = rawArguments.slice(existingArguments.length); + argumentsDelta = missingSuffix.length > 0 ? missingSuffix : undefined; + } else if (existingArguments === rawArguments) { + argumentsDelta = undefined; + } + } + + const knownTool = context.toolCalls[outputIndex] || {}; + const shouldBackfillId = !!toolCallId && !knownTool.id; + const shouldBackfillName = !!toolName && !knownTool.name; + if (argumentsDelta === undefined && !shouldBackfillId && !shouldBackfillName) { + return {}; + } + + return { + toolCallDeltas: [{ + index: outputIndex, + id: toolCallId, + name: toolName, + argumentsDelta, + }], + }; + } + + if (type === 'response.completed' && isRecord((payload as any).response)) { + const responsePayload = (payload as any).response as Record; + if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id; + if (isNonEmptyString(responsePayload.model)) context.model = responsePayload.model; + return { + finishReason: normalizeStopReason(responsePayload.status) || 'stop', + done: true, + }; + } + + const message = isRecord(payload.message) ? payload.message : null; + + if (message) { + if (isNonEmptyString(message.id)) context.id = message.id; + if (isNonEmptyString(message.model)) context.model = message.model; + } + if (!context.model) context.model = fallbackModel; + + if (type === 'message_start') { + return { role: 'assistant' }; + } + + if (type === 'content_block_start') { + const index = ( + typeof (payload as any).index === 'number' && Number.isFinite((payload as any).index) + ? Math.max(0, Math.trunc((payload as any).index)) + : 0 + ); + const contentBlock = isRecord(payload.content_block) ? payload.content_block : {}; + if (contentBlock.type === 'tool_use') { + const id = typeof contentBlock.id === 'string' && contentBlock.id.trim().length > 0 + ? contentBlock.id + : undefined; + const name = typeof contentBlock.name === 'string' && contentBlock.name.trim().length > 0 + ? contentBlock.name + : undefined; + let argumentsDelta: string | undefined; + const rawInput = contentBlock.input; + if (typeof rawInput === 'string') { + argumentsDelta = rawInput; + } else if (Array.isArray(rawInput) || isRecord(rawInput)) { + try { + const serialized = JSON.stringify(rawInput); + if (serialized && serialized !== '{}' && serialized !== '[]') { + argumentsDelta = serialized; + } + } catch {} + } + + return { + toolCallDeltas: [{ + index, + id, + name, + argumentsDelta, + }], + }; + } + + const parsed = extractTextAndReasoning(payload.content_block); + return { + contentDelta: parsed.content || undefined, + reasoningDelta: parsed.reasoning || undefined, + }; + } + + if (type === 'content_block_delta') { + const delta = isRecord(payload.delta) ? payload.delta : {}; + const deltaType = typeof delta.type === 'string' ? delta.type : ''; + const parsed = extractTextAndReasoning(delta); + + if (deltaType === 'input_json_delta') { + const index = ( + typeof (payload as any).index === 'number' && Number.isFinite((payload as any).index) + ? Math.max(0, Math.trunc((payload as any).index)) + : 0 + ); + const partialJson = typeof (delta as any).partial_json === 'string' + ? (delta as any).partial_json + : undefined; + return { + toolCallDeltas: [{ + index, + argumentsDelta: partialJson, + }], + }; + } + + if (deltaType === 'thinking_delta') { + return { + reasoningDelta: parsed.content || parsed.reasoning || undefined, + }; + } + + return { + contentDelta: parsed.content || undefined, + reasoningDelta: parsed.reasoning || undefined, + }; + } + + if (type === 'message_delta') { + const delta = isRecord(payload.delta) ? payload.delta : {}; + return { + finishReason: normalizeStopReason(delta.stop_reason ?? payload.stop_reason), + }; + } + + if (type === 'message_stop') { + return { done: true }; + } + + if (Array.isArray(payload.candidates)) { + const candidate = payload.candidates[0] || {}; + const parsed = extractTextAndReasoning((candidate as any).content?.parts || (candidate as any).content); + + if (isNonEmptyString((payload as any).modelVersion)) { + context.model = (payload as any).modelVersion; + } else if (!context.model) { + context.model = fallbackModel; + } + + return { + contentDelta: parsed.content || undefined, + reasoningDelta: parsed.reasoning || undefined, + finishReason: normalizeStopReason((candidate as any).finishReason || (payload as any).finishReason), + }; + } + + const fallback = extractTextAndReasoning(payload); + return { + contentDelta: fallback.content || undefined, + reasoningDelta: fallback.reasoning || undefined, + }; +} + +function buildOpenAiStreamChunk( + context: StreamTransformContext, + event: NormalizedStreamEvent, +): Record | null { + const delta: Record = {}; + const isInitialAssistantRoleOnlyEvent = ( + !context.roleSent + && event.role === 'assistant' + && !event.contentDelta + && !event.reasoningDelta + ); + + if (!context.roleSent && (event.role === 'assistant' || event.contentDelta || event.reasoningDelta)) { + delta.role = 'assistant'; + context.roleSent = true; + } else if (event.role === 'assistant') { + delta.role = 'assistant'; + context.roleSent = true; + } + + if (event.contentDelta) { + delta.content = event.contentDelta; + } + + if (event.reasoningDelta) { + delta.reasoning_content = event.reasoningDelta; + } + + if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { + const toolCalls = event.toolCallDeltas.map((toolDelta) => { + const index = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0; + const existing = context.toolCalls[index] || {}; + const id = toolDelta.id || existing.id || `call_meta_${index}`; + const name = toolDelta.name || existing.name || ''; + const nextArguments = `${existing.arguments || ''}${toolDelta.argumentsDelta ?? ''}`; + context.toolCalls[index] = { + id, + name: name || existing.name, + arguments: nextArguments, + }; + + const fn: Record = {}; + if (name) fn.name = name; + if (toolDelta.argumentsDelta !== undefined) fn.arguments = toolDelta.argumentsDelta; + + return { + index, + id, + type: 'function', + function: fn, + }; + }); + + if (toolCalls.length > 0) { + delta.tool_calls = toolCalls; + } + } + + // Some OpenAI-compatible clients (e.g. OpenWebUI) expect starter chunk to include empty content. + if (isInitialAssistantRoleOnlyEvent) { + delta.content = ''; + } + + const finishReason = event.finishReason || null; + const hasDelta = Object.keys(delta).length > 0; + if (!hasDelta && !finishReason) return null; + + return { + id: context.id, + object: 'chat.completion.chunk', + created: context.created, + model: context.model, + choices: [{ + index: 0, + delta, + finish_reason: finishReason, + }], + }; +} + +function ensureClaudeStartEvents( + context: StreamTransformContext, + claudeContext: ClaudeDownstreamContext, +): string[] { + if (claudeContext.messageStarted) return []; + + claudeContext.messageStarted = true; + const payload = { + type: 'message_start', + message: { + id: buildClaudeMessageId(context.id), + type: 'message', + role: 'assistant', + model: context.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + }, + }; + + return [serializeSse('message_start', payload)]; +} + +function ensureClaudeTextBlockStart( + claudeContext: ClaudeDownstreamContext, +): string[] { + if (claudeContext.contentBlockStarted && claudeContext.textBlockIndex !== null) return []; + const contentIndex = claudeContext.nextContentBlockIndex; + claudeContext.nextContentBlockIndex += 1; + claudeContext.contentBlockStarted = true; + claudeContext.textBlockIndex = contentIndex; + + return [serializeSse('content_block_start', { + type: 'content_block_start', + index: contentIndex, + content_block: { + type: 'text', + text: '', + }, + })]; +} + +function closeClaudeTextBlock( + claudeContext: ClaudeDownstreamContext, +): string[] { + if (!claudeContext.contentBlockStarted || claudeContext.textBlockIndex === null) return []; + + const contentIndex = claudeContext.textBlockIndex; + claudeContext.contentBlockStarted = false; + claudeContext.textBlockIndex = null; + return [serializeSse('content_block_stop', { + type: 'content_block_stop', + index: contentIndex, + })]; +} + +function normalizeToolContentIndex(raw: unknown): number { + if (typeof raw === 'number' && Number.isFinite(raw)) { + return Math.max(0, Math.trunc(raw)); + } + return 0; +} + +function ensureClaudeToolBlockStart( + claudeContext: ClaudeDownstreamContext, + toolDelta: NonNullable[number], +): { events: string[]; contentIndex: number } { + const toolSlot = normalizeToolContentIndex(toolDelta.index); + let state = claudeContext.toolBlocks[toolSlot]; + if (!state) { + const fallbackId = `toolu_meta_${toolSlot}`; + const fallbackName = `tool_${toolSlot}`; + state = { + contentIndex: claudeContext.nextContentBlockIndex, + id: toolDelta.id || fallbackId, + name: toolDelta.name || fallbackName, + open: false, + }; + claudeContext.nextContentBlockIndex += 1; + claudeContext.toolBlocks[toolSlot] = state; + } else { + if (toolDelta.id && state.id !== toolDelta.id) { + state.id = toolDelta.id; + } + if (toolDelta.name && state.name !== toolDelta.name) { + state.name = toolDelta.name; + } + } + + const events: string[] = []; + if (!state.open) { + state.open = true; + events.push(serializeSse('content_block_start', { + type: 'content_block_start', + index: state.contentIndex, + content_block: { + type: 'tool_use', + id: state.id, + name: state.name, + input: {}, + }, + })); + } + + return { + events, + contentIndex: state.contentIndex, + }; +} + +function closeClaudeToolBlocks( + claudeContext: ClaudeDownstreamContext, +): string[] { + const openBlocks = Object.values(claudeContext.toolBlocks) + .filter((item) => item.open) + .sort((a, b) => a.contentIndex - b.contentIndex); + if (openBlocks.length <= 0) return []; + + const events: string[] = []; + for (const block of openBlocks) { + block.open = false; + events.push(serializeSse('content_block_stop', { + type: 'content_block_stop', + index: block.contentIndex, + })); + } + return events; +} + +function buildClaudeDoneEvents( + context: StreamTransformContext, + claudeContext: ClaudeDownstreamContext, + finishReason?: string | null, +): string[] { + if (claudeContext.doneSent) return []; + + const events: string[] = []; + events.push(...ensureClaudeStartEvents(context, claudeContext)); + + events.push(...closeClaudeTextBlock(claudeContext)); + events.push(...closeClaudeToolBlocks(claudeContext)); + + events.push(serializeSse('message_delta', { + type: 'message_delta', + delta: { + stop_reason: toClaudeStopReason(finishReason), + stop_sequence: null, + }, + usage: { + output_tokens: 0, + }, + })); + events.push(serializeSse('message_stop', { type: 'message_stop' })); + + claudeContext.doneSent = true; + return events; +} + +export function serializeNormalizedStreamEvent( + downstreamFormat: DownstreamFormat, + event: NormalizedStreamEvent, + context: StreamTransformContext, + claudeContext: ClaudeDownstreamContext, +): string[] { + if (downstreamFormat === 'openai') { + const chunk = buildOpenAiStreamChunk(context, event); + return chunk ? [serializeSse('', chunk)] : []; + } + + const events: string[] = []; + if (event.role === 'assistant' || event.contentDelta || event.reasoningDelta) { + events.push(...ensureClaudeStartEvents(context, claudeContext)); + } + + const mergedText = joinNonEmpty([ + event.reasoningDelta || '', + event.contentDelta || '', + ]); + + if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { + events.push(...closeClaudeTextBlock(claudeContext)); + for (const toolDelta of event.toolCallDeltas) { + const toolBlock = ensureClaudeToolBlockStart(claudeContext, toolDelta); + events.push(...toolBlock.events); + + if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) { + events.push(serializeSse('content_block_delta', { + type: 'content_block_delta', + index: toolBlock.contentIndex, + delta: { + type: 'input_json_delta', + partial_json: toolDelta.argumentsDelta, + }, + })); + } + } + } + + if (mergedText) { + events.push(...closeClaudeToolBlocks(claudeContext)); + events.push(...ensureClaudeTextBlockStart(claudeContext)); + events.push(serializeSse('content_block_delta', { + type: 'content_block_delta', + index: claudeContext.textBlockIndex ?? 0, + delta: { + type: 'text_delta', + text: mergedText, + }, + })); + } + + if (event.done || event.finishReason) { + events.push(...buildClaudeDoneEvents(context, claudeContext, event.finishReason)); + } + + return events; +} + +export function serializeStreamDone( + downstreamFormat: DownstreamFormat, + context: StreamTransformContext, + claudeContext: ClaudeDownstreamContext, +): string[] { + if (context.doneSent) return []; + context.doneSent = true; + + if (downstreamFormat === 'openai') { + return [serializeSse('', '[DONE]')]; + } + + return buildClaudeDoneEvents(context, claudeContext, 'stop'); +} + +function toOpenAiToolCalls( + toolCalls: Array<{ id: string; name: string; arguments: string }>, +): Array> { + return toolCalls.map((toolCall, index) => ({ + index, + id: toolCall.id || `call_${index}`, + type: 'function', + function: { + name: toolCall.name || '', + arguments: toolCall.arguments || '', + }, + })); +} + +export function serializeFinalResponse( + downstreamFormat: DownstreamFormat, + normalized: NormalizedFinalResponse, + usage: { promptTokens: number; completionTokens: number; totalTokens: number }, +): Record { + const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : []; + + if (downstreamFormat === 'claude') { + const contentBlocks: Array> = []; + if (normalized.reasoningContent) { + contentBlocks.push({ + type: 'thinking', + thinking: normalized.reasoningContent, + }); + } + if (normalized.content) { + contentBlocks.push({ + type: 'text', + text: normalized.content, + }); + } + for (let index = 0; index < toolCalls.length; index += 1) { + const toolCall = toolCalls[index]; + contentBlocks.push({ + type: 'tool_use', + id: toolCall.id || `toolu_${index}`, + name: toolCall.name || `tool_${index}`, + input: parseJsonLike(toolCall.arguments || ''), + }); + } + + const content = contentBlocks.length > 0 + ? contentBlocks + : [{ type: 'text', text: '' }]; + + return { + id: buildClaudeMessageId(normalized.id), + type: 'message', + role: 'assistant', + model: normalized.model, + content, + stop_reason: toClaudeStopReason(normalized.finishReason), + stop_sequence: null, + usage: { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + }, + }; + } + + const message: Record = { + role: 'assistant', + content: normalized.content, + }; + if (normalized.reasoningContent) { + message.reasoning_content = normalized.reasoningContent; + } + if (toolCalls.length > 0) { + message.tool_calls = toOpenAiToolCalls(toolCalls); + if (!normalized.content) message.content = ''; + } + + const finishReason = ( + toolCalls.length > 0 + ? 'tool_calls' + : (normalizeStopReason(normalized.finishReason) || 'stop') + ); + + return { + id: normalized.id, + object: 'chat.completion', + created: normalized.created, + model: normalized.model, + choices: [{ + index: 0, + message, + finish_reason: finishReason, + }], + usage: { + prompt_tokens: usage.promptTokens, + completion_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + }; +} + +export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse): Array> { + const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : []; + const finishReason = ( + toolCalls.length > 0 + ? 'tool_calls' + : (normalizeStopReason(normalized.finishReason) || 'stop') + ); + + const startDelta: Record = { + role: 'assistant', + }; + if (normalized.content) { + startDelta.content = normalized.content; + } else { + startDelta.content = ''; + } + if (normalized.reasoningContent) { + startDelta.reasoning_content = normalized.reasoningContent; + } + if (toolCalls.length > 0) { + startDelta.tool_calls = toOpenAiToolCalls(toolCalls); + } + + const startChunk: Record = { + id: normalized.id, + object: 'chat.completion.chunk', + created: normalized.created, + model: normalized.model, + choices: [{ + index: 0, + delta: startDelta, + finish_reason: null, + }], + }; + + const endChunk = { + id: normalized.id, + object: 'chat.completion.chunk', + created: normalized.created, + model: normalized.model, + choices: [{ + index: 0, + delta: {}, + finish_reason: finishReason, + }], + }; + + return [startChunk, endChunk]; +} + +export function pullSseEventsWithDone(buffer: string): { events: ParsedSseEvent[]; rest: string } { + const normalized = buffer.replace(/\r\n/g, '\n'); + const events: ParsedSseEvent[] = []; + let rest = normalized; + + while (true) { + const boundary = rest.indexOf('\n\n'); + if (boundary < 0) break; + + const block = rest.slice(0, boundary); + rest = rest.slice(boundary + 2); + + if (!block.trim()) continue; + + const lines = block.split('\n'); + let eventName = ''; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + continue; + } + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (dataLines.length <= 0) continue; + + events.push({ + event: eventName, + data: dataLines.join('\n').trim(), + }); + } + + return { events, rest }; +} diff --git a/src/server/transformers/shared/normalized.test.ts b/src/server/transformers/shared/normalized.test.ts new file mode 100644 index 0000000..ef59465 --- /dev/null +++ b/src/server/transformers/shared/normalized.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { + normalizeStopReason, + normalizeUpstreamFinalResponse, + pullSseEventsWithDone, + serializeFinalResponse, + type NormalizedFinalResponse, +} from './normalized.js'; + +describe('shared normalized helpers', () => { + it('does not depend on route-level chatFormats helpers', () => { + const source = readFileSync(new URL('./normalized.ts', import.meta.url), 'utf8'); + expect(source).not.toContain('routes/proxy/chatFormats'); + expect(source).not.toContain('chatFormats.js'); + }); + + it('parses SSE events and keeps the trailing partial block', () => { + const pulled = pullSseEventsWithDone([ + 'event: message', + 'data: {"id":"1"}', + '', + 'data: [DONE]', + '', + 'data: {"partial":true}', + ].join('\n')); + + expect(pulled.events).toEqual([ + { event: 'message', data: '{"id":"1"}' }, + { event: '', data: '[DONE]' }, + ]); + expect(pulled.rest).toBe('data: {"partial":true}'); + }); + + it('normalizes responses payloads with tool calls', () => { + expect(normalizeUpstreamFinalResponse({ + object: 'response', + id: 'resp_1', + model: 'gpt-test', + created: 123, + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'hello' }], + }, + { + type: 'function_call', + call_id: 'call_1', + name: 'lookup', + arguments: '{"q":"x"}', + }, + ], + status: 'completed', + }, 'fallback-model')).toEqual({ + id: 'resp_1', + model: 'gpt-test', + created: 123, + content: 'hello', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_1', + name: 'lookup', + arguments: '{"q":"x"}', + }], + }); + }); + + it('serializes normalized final responses for claude', () => { + const normalized: NormalizedFinalResponse = { + id: 'chatcmpl-1', + model: 'claude-test', + created: 456, + content: 'done', + reasoningContent: 'thinking', + finishReason: 'tool_calls', + toolCalls: [{ id: 'tool_1', name: 'lookup', arguments: '{"q":"x"}' }], + }; + + expect(serializeFinalResponse('claude', normalized, { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + })).toEqual({ + id: 'msg_chatcmpl-1', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { type: 'thinking', thinking: 'thinking' }, + { type: 'text', text: 'done' }, + { type: 'tool_use', id: 'tool_1', name: 'lookup', input: { q: 'x' } }, + ], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 5, + }, + }); + }); + + it('normalizes known stop reasons', () => { + expect(normalizeStopReason('max_output_tokens')).toBe('length'); + expect(normalizeStopReason('tool_use')).toBe('tool_calls'); + expect(normalizeStopReason('completed')).toBe('stop'); + expect(normalizeStopReason('mystery')).toBeNull(); + }); +}); diff --git a/src/server/transformers/shared/normalized.ts b/src/server/transformers/shared/normalized.ts new file mode 100644 index 0000000..0522b5f --- /dev/null +++ b/src/server/transformers/shared/normalized.ts @@ -0,0 +1,136 @@ +export { + buildSyntheticOpenAiChunks, + createClaudeDownstreamContext, + createStreamTransformContext, + normalizeStopReason, + normalizeUpstreamFinalResponse, + normalizeUpstreamStreamEvent, + parseDownstreamChatRequest, + pullSseEventsWithDone, + serializeFinalResponse, + serializeNormalizedStreamEvent, + serializeStreamDone, + toClaudeStopReason, + type ClaudeDownstreamContext, + type DownstreamFormat, + type NormalizedFinalResponse, + type NormalizedStreamEvent, + type ParsedDownstreamChatRequest, + type ParsedSseEvent, + type StreamTransformContext, +} from './chatFormatsCore.js'; + +export type NormalizedContentBlockType = + | 'text' + | 'image_url' + | 'image_inline' + | 'input_audio' + | 'output_audio' + | 'tool_call' + | 'tool_result' + | 'function_response' + | 'reasoning' + | 'redacted_reasoning'; + +export type NormalizedContentBlock = { + type: NormalizedContentBlockType; + role?: string | null; + text?: string | null; + mimeType?: string | null; + url?: string | null; + data?: string | null; + toolName?: string | null; + toolCallId?: string | null; + argumentsText?: string | null; + result?: unknown; + metadata?: Record | null; +}; + +export type NormalizedUsage = { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; + cachedTokens?: number | null; + reasoningTokens?: number | null; + audioInputTokens?: number | null; + audioOutputTokens?: number | null; + acceptedPredictionTokens?: number | null; + rejectedPredictionTokens?: number | null; +}; + +export type ParsedDownstreamChatRequestResult = { + value?: import('./chatFormatsCore.js').ParsedDownstreamChatRequest; + error?: { statusCode: number; payload: unknown }; +}; + +export type TransformerMetadata = { + include?: unknown; + maxToolCalls?: number | null; + promptCacheRetention?: unknown; + includeObfuscation?: boolean | null; + citations?: unknown; + annotations?: unknown; + geminiSafetySettings?: unknown; + geminiImageConfig?: unknown; + thoughtSignature?: string | null; + passthrough?: Record; +}; + +export type NormalizedRequest = { + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + model: string; + stream: boolean; + rawBody: unknown; + parsed: import('./chatFormatsCore.js').ParsedDownstreamChatRequest | null; + contentBlocks?: NormalizedContentBlock[]; + metadata?: TransformerMetadata; +}; + +export type NormalizedResponseEnvelope = { + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + model: string; + final: import('./chatFormatsCore.js').NormalizedFinalResponse; + usage?: unknown; + contentBlocks?: NormalizedContentBlock[]; + metadata?: TransformerMetadata; +}; + +export type NormalizedStreamEnvelope = { + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + model: string; + event: import('./chatFormatsCore.js').NormalizedStreamEvent; + metadata?: TransformerMetadata; +}; + +export function createEmptyNormalizedUsage(): NormalizedUsage { + return { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0, + reasoningTokens: 0, + audioInputTokens: 0, + audioOutputTokens: 0, + acceptedPredictionTokens: 0, + rejectedPredictionTokens: 0, + }; +} + +export function mergeNormalizedUsage( + base: NormalizedUsage | undefined, + next: NormalizedUsage | undefined, +): NormalizedUsage { + const merged = { ...createEmptyNormalizedUsage(), ...(base || {}) }; + const incoming = next || {}; + return { + promptTokens: (merged.promptTokens || 0) + (incoming.promptTokens || 0), + completionTokens: (merged.completionTokens || 0) + (incoming.completionTokens || 0), + totalTokens: (merged.totalTokens || 0) + (incoming.totalTokens || 0), + cachedTokens: (merged.cachedTokens || 0) + (incoming.cachedTokens || 0), + reasoningTokens: (merged.reasoningTokens || 0) + (incoming.reasoningTokens || 0), + audioInputTokens: (merged.audioInputTokens || 0) + (incoming.audioInputTokens || 0), + audioOutputTokens: (merged.audioOutputTokens || 0) + (incoming.audioOutputTokens || 0), + acceptedPredictionTokens: (merged.acceptedPredictionTokens || 0) + (incoming.acceptedPredictionTokens || 0), + rejectedPredictionTokens: (merged.rejectedPredictionTokens || 0) + (incoming.rejectedPredictionTokens || 0), + }; +} diff --git a/src/web/App.tsx b/src/web/App.tsx index eb2451c..751186c 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -5,6 +5,12 @@ import SearchModal from './components/SearchModal.js'; import NotificationPanel from './components/NotificationPanel.js'; import { api } from './api.js'; import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js'; +import { + FIRST_USE_DOC_REMINDER_KEY, + LEGACY_THEME_STORAGE_KEY, + THEME_MODE_STORAGE_KEY, + USER_PROFILE_STORAGE_KEY, +} from './appLocalState.js'; import { I18nProvider, useI18n } from './i18n.js'; import { resolveLoginErrorMessage } from './loginError.js'; import { SITE_DOCS_URL } from './docsLink.js'; @@ -32,10 +38,6 @@ type UserProfile = { avatarSeed: string; avatarStyle: string; }; - -const THEME_MODE_STORAGE_KEY = 'theme_mode'; -const USER_PROFILE_STORAGE_KEY = 'user_profile'; -const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1'; const DICEBEAR_STYLES = [ 'pixel-art', 'pixel-art-neutral', @@ -56,7 +58,7 @@ type DicebearStyle = typeof DICEBEAR_STYLES[number]; function resolveStoredThemeMode(): ThemeMode { const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY); if (saved === 'system' || saved === 'light' || saved === 'dark') return saved; - const legacy = localStorage.getItem('theme'); + const legacy = localStorage.getItem(LEGACY_THEME_STORAGE_KEY); if (legacy === 'light' || legacy === 'dark') return legacy; return 'system'; } diff --git a/src/web/api.ts b/src/web/api.ts index 1ee7cd2..f9f4861 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -87,7 +87,7 @@ async function request(url: string, options: RequestOptions = {}) { type TestChatRequestPayload = { model: string; messages: Array<{ role: string; content: string }>; - targetFormat?: 'openai' | 'claude' | 'responses'; + targetFormat?: 'openai' | 'claude' | 'responses' | 'gemini'; stream?: boolean; temperature?: number; top_p?: number; @@ -97,6 +97,39 @@ type TestChatRequestPayload = { seed?: number; }; +export type ProxyTestMethod = 'POST' | 'GET' | 'DELETE'; +export type ProxyTestRequestKind = 'json' | 'multipart' | 'empty'; + +export type ProxyTestMultipartFile = { + field: string; + name: string; + mimeType: string; + dataUrl: string; +}; + +export type ProxyTestRequestEnvelope = { + method: ProxyTestMethod; + path: string; + requestKind: ProxyTestRequestKind; + stream?: boolean; + jobMode?: boolean; + rawMode?: boolean; + jsonBody?: unknown; + rawJsonText?: string; + multipartFields?: Record; + multipartFiles?: ProxyTestMultipartFile[]; +}; + +export type ProxyTestJobResponse = { + jobId: string; + status: 'pending' | 'succeeded' | 'failed' | 'cancelled'; + result?: unknown; + error?: unknown; + createdAt?: string; + updatedAt?: string; + expiresAt?: string; +}; + export const api = { // Sites getSites: () => request('/api/sites'), @@ -161,17 +194,29 @@ export const api = { timeoutMs: wait ? 150_000 : 30_000, }), getRouteDecision: (model: string) => request(`/api/routes/decision?model=${encodeURIComponent(model)}`), - getRouteDecisionsBatch: (models: string[]) => request('/api/routes/decision/batch', { + getRouteDecisionsBatch: (models: string[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/batch', { method: 'POST', - body: JSON.stringify({ models }), + body: JSON.stringify({ + models, + ...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}), + ...(options?.persistSnapshots ? { persistSnapshots: true } : {}), + }), }), - getRouteDecisionsByRouteBatch: (items: Array<{ routeId: number; model: string }>) => request('/api/routes/decision/by-route/batch', { + getRouteDecisionsByRouteBatch: (items: Array<{ routeId: number; model: string }>, options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/by-route/batch', { method: 'POST', - body: JSON.stringify({ items }), + body: JSON.stringify({ + items, + ...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}), + ...(options?.persistSnapshots ? { persistSnapshots: true } : {}), + }), }), - getRouteWideDecisionsBatch: (routeIds: number[]) => request('/api/routes/decision/route-wide/batch', { + getRouteWideDecisionsBatch: (routeIds: number[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/route-wide/batch', { method: 'POST', - body: JSON.stringify({ routeIds }), + body: JSON.stringify({ + routeIds, + ...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}), + ...(options?.persistSnapshots ? { persistSnapshots: true } : {}), + }), }), // Stats @@ -246,6 +291,7 @@ export const api = { }), clearRuntimeCache: () => request('/api/settings/maintenance/clear-cache', { method: 'POST' }), clearUsageData: () => request('/api/settings/maintenance/clear-usage', { method: 'POST' }), + factoryReset: () => request('/api/settings/maintenance/factory-reset', { method: 'POST' }), testNotification: () => request('/api/settings/notify/test', { method: 'POST' }), // Monitor embed @@ -271,8 +317,48 @@ export const api = { request('/api/test/chat/jobs', { method: 'POST', body: JSON.stringify(data) }), getTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`), deleteTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }), + startProxyTestJob: (data: ProxyTestRequestEnvelope) => + request('/api/test/proxy/jobs', { method: 'POST', body: JSON.stringify(data) }), + getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`), + deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }), + testProxy: (data: ProxyTestRequestEnvelope) => + request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }), + proxyTest: (data: ProxyTestRequestEnvelope) => + request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }), testChat: (data: TestChatRequestPayload) => request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }), + testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => { + const token = getAuthToken(localStorage); + if (!token) { + clearAuthSession(localStorage); + throw new Error('Session expired'); + } + return fetch('/api/test/proxy/stream', { + method: 'POST', + signal, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + }, + proxyTestStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => { + const token = getAuthToken(localStorage); + if (!token) { + clearAuthSession(localStorage); + throw new Error('Session expired'); + } + return fetch('/api/test/proxy/stream', { + method: 'POST', + signal, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + }, testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => { const token = getAuthToken(localStorage); if (!token) { diff --git a/src/web/appLocalState.ts b/src/web/appLocalState.ts new file mode 100644 index 0000000..0069a53 --- /dev/null +++ b/src/web/appLocalState.ts @@ -0,0 +1,28 @@ +import { clearAuthSession } from './authSession.js'; + +export const THEME_MODE_STORAGE_KEY = 'theme_mode'; +export const LEGACY_THEME_STORAGE_KEY = 'theme'; +export const USER_PROFILE_STORAGE_KEY = 'user_profile'; +export const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1'; + +type StorageLike = { + getItem?: (key: string) => string | null; + setItem?: (key: string, value: string) => void; + removeItem: (key: string) => void; +}; + +function resolveStorage(storage?: StorageLike | null): StorageLike | null { + if (storage) return storage; + if (typeof localStorage !== 'undefined') return localStorage; + return null; +} + +export function clearAppInstallationState(storage?: StorageLike | null): void { + const target = resolveStorage(storage); + if (!target) return; + clearAuthSession(target as never); + target.removeItem(THEME_MODE_STORAGE_KEY); + target.removeItem(LEGACY_THEME_STORAGE_KEY); + target.removeItem(USER_PROFILE_STORAGE_KEY); + target.removeItem(FIRST_USE_DOC_REMINDER_KEY); +} diff --git a/src/web/components/BrandIcon.render.test.tsx b/src/web/components/BrandIcon.render.test.tsx new file mode 100644 index 0000000..261faa3 --- /dev/null +++ b/src/web/components/BrandIcon.render.test.tsx @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { create } from 'react-test-renderer'; +import { BrandIcon } from './BrandIcon.js'; + +describe('BrandIcon rendering', () => { + it('does not force a colored background shell for recognized brands', () => { + const root = create(); + const wrappers = root.root.findAll((node) => node.type === 'div'); + const brandedWrapper = wrappers.find((node) => node.props.style?.width === 44 && node.props.style?.height === 44); + + expect(brandedWrapper).toBeDefined(); + expect(brandedWrapper?.props.style?.background).not.toBe('linear-gradient(135deg, #76b900, #4a8c0b)'); + }); +}); diff --git a/src/web/components/BrandIcon.test.ts b/src/web/components/BrandIcon.test.ts index 5140e7a..1081dd2 100644 --- a/src/web/components/BrandIcon.test.ts +++ b/src/web/components/BrandIcon.test.ts @@ -17,7 +17,41 @@ describe('getBrand', () => { expect(getBrand('openrouter/google/gemini-2.5-pro')?.name).toBe('Google'); }); + it('detects issue #38 existing-brand additions', () => { + expect(getBrand('google/shieldgemma-9b')).toMatchObject({ name: 'Google', icon: expect.any(String) }); + expect(getBrand('PaLM-2')).toMatchObject({ name: 'Google', icon: expect.any(String) }); + expect(getBrand('imagen3-fast')).toMatchObject({ name: 'Google', icon: expect.any(String) }); + expect(getBrand('veo3-pro')).toMatchObject({ name: 'Google', icon: expect.any(String) }); + expect(getBrand('davinci-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) }); + expect(getBrand('babbage-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) }); + expect(getBrand('codex-mini-2025-05-16')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) }); + expect(getBrand('ds-chat')).toMatchObject({ name: 'DeepSeek', icon: expect.any(String) }); + expect(getBrand('tongyi-deepresearch-30b-a3b')).toMatchObject({ name: '通义千问', icon: expect.any(String) }); + expect(getBrand('microsoft/kosmos-2')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) }); + expect(getBrand('phi4')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) }); + expect(getBrand('stablediffusion3.5-l')).toMatchObject({ name: 'Stability', icon: expect.any(String) }); + expect(getBrand('sd3-medium')).toMatchObject({ name: 'Stability', icon: expect.any(String) }); + expect(getBrand('tencent-hunyuanvideo-hd')).toMatchObject({ name: '腾讯混元', icon: expect.any(String) }); + expect(getBrand('mini2.1')).toMatchObject({ name: 'MiniMax', icon: expect.any(String) }); + expect(getBrand('stepfun-ai/step3')).toMatchObject({ name: '阶跃星辰', icon: expect.any(String) }); + }); + + it('detects issue #38 newly added brands with registered icons', () => { + expect(getBrand('nvidia/vila')).toMatchObject({ name: 'NVIDIA', icon: expect.any(String) }); + expect(getBrand('ibm/granite-3.3-8b-instruct')).toMatchObject({ name: 'IBM', icon: expect.any(String) }); + expect(getBrand('BAAI/bge-m3')).toMatchObject({ name: 'BAAI', icon: expect.any(String) }); + expect(getBrand('bytedance/seed-oss-36b-instruct')).toMatchObject({ name: 'ByteDance', icon: expect.any(String) }); + expect(getBrand('internlm/internlm2_5-7b-chat')).toMatchObject({ name: 'InternLM', icon: expect.any(String) }); + expect(getBrand('mj_turbo')).toMatchObject({ name: 'Midjourney', icon: expect.any(String) }); + expect(getBrand('deepl-zh-en')).toMatchObject({ name: 'DeepL', icon: expect.any(String) }); + expect(getBrand('jina-embeddings-v3')).toMatchObject({ name: 'Jina AI', icon: expect.any(String) }); + }); + it('returns null for unknown model names', () => { expect(getBrand('totally-unknown-model')).toBeNull(); }); + + it('does not misclassify GPTQ llama variants as OpenAI', () => { + expect(getBrand('TheBloke/Llama-2-7B-GPTQ')?.name).toBe('Meta'); + }); }); diff --git a/src/web/components/BrandIcon.tsx b/src/web/components/BrandIcon.tsx index b0d8829..73f0002 100644 --- a/src/web/components/BrandIcon.tsx +++ b/src/web/components/BrandIcon.tsx @@ -1,290 +1,557 @@ -import React, { useState, useEffect } from 'react'; -const ICON_CDN = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/dark'; -const ICON_CDN_LIGHT = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light'; +import React, { useEffect, useState, type CSSProperties } from 'react'; + +const BRAND_ICON_VERSION = '1.83.0'; +const ICON_CDN = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/dark`; +const ICON_CDN_LIGHT = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/light`; + +const LEGACY_ICON_ALIASES: Record = { + 'anthropic': 'claude-color', + 'claude.color': 'claude-color', + 'cohere.color': 'cohere-color', + 'doubao.color': 'doubao-color', + 'gemini.color': 'gemini-color', + 'hunyuan.color': 'hunyuan-color', + 'meta': 'meta-color', + 'meta-brand-color': 'meta-color', + 'minimax.color': 'minimax-color', + 'qwen.color': 'qwen-color', + 'spark.color': 'spark-color', + 'stability': 'stability-color', + 'stability-brand-color': 'stability-color', + 'stepfun': 'stepfun-color', + 'wenxin.color': 'wenxin-color', + 'xai': 'xai', + 'yi.color': 'yi-color', + 'zhipu.color': 'zhipu-color', + 'azure': 'microsoft-color', + 'bytedance-brand-color': 'bytedance-color', +}; + export function useIconCdn() { - const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark'); - useEffect(() => { - const observer = new MutationObserver(() => { - setIsDark(document.documentElement.getAttribute('data-theme') === 'dark'); - }); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); - return () => observer.disconnect(); - }, []); - return isDark ? ICON_CDN : ICON_CDN_LIGHT; -} - -export interface BrandInfo { - name: string; - icon: string; - color: string; -} - -const BRAND_MAP: Array<{ prefixes: string[]; brand: BrandInfo }> = [ - { - prefixes: ['gpt', 'o1', 'o3', 'o4', 'chatgpt', 'dall-e', 'text-embedding', 'text-davinci', 'text-curie', 'text-babbage', 'text-ada', 'whisper', 'tts'], - brand: { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg, #10a37f, #1a7f5a)' } - }, - { - prefixes: ['claude'], - brand: { name: 'Anthropic', icon: 'claude.color', color: 'linear-gradient(135deg, #d4a574, #c4956a)' } - }, - { - prefixes: ['gemini', 'gemma'], - brand: { name: 'Google', icon: 'gemini.color', color: 'linear-gradient(135deg, #4285f4, #34a853)' } - }, - { - prefixes: ['deepseek'], - brand: { name: 'DeepSeek', icon: 'deepseek.color', color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)' } - }, - { - prefixes: ['qwen', 'qwq'], - brand: { name: '通义千问', icon: 'qwen.color', color: 'linear-gradient(135deg, #615cf7, #9b8afb)' } - }, - { - prefixes: ['glm', 'chatglm', 'codegeex', 'cogview'], - brand: { name: '智谱 AI', icon: 'zhipu.color', color: 'linear-gradient(135deg, #3b6cf5, #6366f1)' } - }, - { - prefixes: ['llama', 'code-llama', 'codellama'], - brand: { name: 'Meta', icon: 'meta', color: 'linear-gradient(135deg, #0668E1, #1877f2)' } - }, - { - prefixes: ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral'], - brand: { name: 'Mistral', icon: 'mistral.color', color: 'linear-gradient(135deg, #f7d046, #f2a900)' } - }, - { - prefixes: ['moonshot', 'kimi'], - brand: { name: 'Moonshot', icon: 'moonshot', color: 'linear-gradient(135deg, #000000, #333333)' } - }, - { - prefixes: ['yi-'], - brand: { name: '零一万物', icon: 'yi.color', color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)' } - }, - { - prefixes: ['ernie', 'eb-'], - brand: { name: '文心一言', icon: 'wenxin.color', color: 'linear-gradient(135deg, #2932e1, #4468f2)' } - }, - { - prefixes: ['spark', 'generalv'], - brand: { name: '讯飞星火', icon: 'spark.color', color: 'linear-gradient(135deg, #0070f3, #00d4ff)' } - }, - { - prefixes: ['hunyuan'], - brand: { name: '腾讯混元', icon: 'hunyuan.color', color: 'linear-gradient(135deg, #00b7ff, #0052d9)' } - }, - { - prefixes: ['doubao'], - brand: { name: '豆包', icon: 'doubao.color', color: 'linear-gradient(135deg, #3b5bdb, #7048e8)' } - }, - { - prefixes: ['minimax', 'abab'], - brand: { name: 'MiniMax', icon: 'minimax.color', color: 'linear-gradient(135deg, #6366f1, #818cf8)' } - }, - { - prefixes: ['command', 'embed-'], - brand: { name: 'Cohere', icon: 'cohere.color', color: 'linear-gradient(135deg, #39594d, #5ba77f)' } - }, - { - prefixes: ['phi-'], - brand: { name: 'Microsoft', icon: 'azure', color: 'linear-gradient(135deg, #00bcf2, #0078d4)' } - }, - { - prefixes: ['grok'], - brand: { name: 'xAI', icon: 'xai', color: 'linear-gradient(135deg, #111, #444)' } - }, - { - prefixes: ['step-'], - brand: { name: '阶跃星辰', icon: 'stepfun', color: 'linear-gradient(135deg, #0066ff, #3399ff)' } - }, - { - prefixes: ['flux', 'sd-', 'stable-diffusion', 'sdxl'], - brand: { name: 'Stability', icon: 'stability', color: 'linear-gradient(135deg, #8b5cf6, #a855f7)' } - }, + const [isDark, setIsDark] = useState(() => { + if (typeof document === 'undefined') return false; + return document.documentElement.getAttribute('data-theme') === 'dark'; + }); + useEffect(() => { + if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return undefined; + const observer = new MutationObserver(() => { + setIsDark(document.documentElement.getAttribute('data-theme') === 'dark'); + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + return () => observer.disconnect(); + }, []); + return isDark ? ICON_CDN : ICON_CDN_LIGHT; +} + +export interface BrandMatchContext { + raw: string; + cleaned: string; + segments: string[]; +} + +interface InternalBrandMatchContext extends BrandMatchContext { + candidates: string[]; +} + +export interface BrandInfo { + name: string; + icon: string; + color: string; +} + +type BrandDefinition = BrandInfo & { + match: (context: InternalBrandMatchContext) => boolean; +}; + +function normalizeInput(value: string): string { + return String(value || '').trim().toLowerCase(); +} + +function stripCommonWrappers(value: string): string { + return value + .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') + .replace(/^re:\s*/g, '') + .replace(/^\^+/, '') + .replace(/\$+$/, '') + .trim(); +} + +function collectBrandCandidates(modelName: string): string[] { + const queue: string[] = []; + const seen = new Set(); + const push = (value: string) => { + const normalized = normalizeInput(value); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + queue.push(normalized); + }; + + push(modelName); + + for (let index = 0; index < queue.length; index += 1) { + const candidate = queue[index]!; + const cleaned = stripCommonWrappers(candidate); + push(cleaned); + + if (cleaned.includes('/')) { + for (const part of cleaned.split('/')) push(part); + } + if (cleaned.includes(':')) { + for (const part of cleaned.split(':')) push(part); + } + if (cleaned.includes(',')) { + for (const part of cleaned.split(',')) push(part); + } + } + + return queue; +} + +function buildMatchContext(modelName: string): InternalBrandMatchContext { + const candidates = collectBrandCandidates(modelName); + const raw = candidates[0] || normalizeInput(modelName); + const cleaned = stripCommonWrappers(raw); + const segments = Array.from(new Set( + candidates + .flatMap((candidate) => candidate.split(/[/:,\s]+/g)) + .map((segment) => segment.trim()) + .filter(Boolean), + )); + + return { + raw, + cleaned, + segments, + candidates, + }; +} + +function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean { + return needles.some((needle) => ( + context.raw.includes(needle) + || context.cleaned.includes(needle) + || context.candidates.some((candidate) => candidate.includes(needle)) + )); +} + +function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean { + return needles.some((needle) => ( + context.raw.startsWith(needle) + || context.cleaned.startsWith(needle) + || context.segments.some((segment) => segment.startsWith(needle)) + || context.candidates.some((candidate) => candidate.startsWith(needle)) + )); +} + +function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean { + return needles.some((needle) => context.segments.includes(needle)); +} + +function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean { + return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate)); +} + +const BRAND_DEFINITIONS: BrandDefinition[] = [ + { + name: 'OpenAI', + icon: 'openai', + color: 'linear-gradient(135deg, #10a37f, #1a7f5a)', + match: (context) => ( + startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini']) + || startsWithAny(context, ['o1', 'o3', 'o4', 'tts']) + ), + }, + { + name: 'Anthropic', + icon: 'claude-color', + color: 'linear-gradient(135deg, #d4a574, #c4956a)', + match: (context) => includesAny(context, ['claude']), + }, + { + name: 'Google', + icon: 'gemini-color', + color: 'linear-gradient(135deg, #4285f4, #34a853)', + match: (context) => ( + includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa']) + || startsWithAny(context, ['veo', 'google/']) + ), + }, + { + name: 'DeepSeek', + icon: 'deepseek-color', + color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)', + match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']), + }, + { + name: '通义千问', + icon: 'qwen-color', + color: 'linear-gradient(135deg, #615cf7, #9b8afb)', + match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']), + }, + { + name: '智谱 AI', + icon: 'zhipu-color', + color: 'linear-gradient(135deg, #3b6cf5, #6366f1)', + match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']), + }, + { + name: 'Meta', + icon: 'meta-color', + color: 'linear-gradient(135deg, #0668E1, #1877f2)', + match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']), + }, + { + name: 'Mistral', + icon: 'mistral-color', + color: 'linear-gradient(135deg, #f7d046, #f2a900)', + match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']), + }, + { + name: 'Moonshot', + icon: 'moonshot', + color: 'linear-gradient(135deg, #000000, #333333)', + match: (context) => includesAny(context, ['moonshot', 'kimi']), + }, + { + name: '零一万物', + icon: 'yi-color', + color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)', + match: (context) => ( + startsWithAny(context, ['yi-']) + || matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/) + ), + }, + { + name: '文心一言', + icon: 'wenxin-color', + color: 'linear-gradient(135deg, #2932e1, #4468f2)', + match: (context) => includesAny(context, ['ernie', 'eb-']), + }, + { + name: '讯飞星火', + icon: 'spark-color', + color: 'linear-gradient(135deg, #0070f3, #00d4ff)', + match: (context) => includesAny(context, ['spark', 'generalv']), + }, + { + name: '腾讯混元', + icon: 'hunyuan-color', + color: 'linear-gradient(135deg, #00b7ff, #0052d9)', + match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']), + }, + { + name: '豆包', + icon: 'doubao-color', + color: 'linear-gradient(135deg, #3b5bdb, #7048e8)', + match: (context) => includesAny(context, ['doubao']), + }, + { + name: 'MiniMax', + icon: 'minimax-color', + color: 'linear-gradient(135deg, #6366f1, #818cf8)', + match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']), + }, + { + name: 'Cohere', + icon: 'cohere-color', + color: 'linear-gradient(135deg, #39594d, #5ba77f)', + match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']), + }, + { + name: 'Microsoft', + icon: 'microsoft-color', + color: 'linear-gradient(135deg, #00bcf2, #0078d4)', + match: (context) => ( + includesAny(context, ['microsoft/', 'phi-', 'kosmos']) + || hasExactSegment(context, ['phi4']) + ), + }, + { + name: 'xAI', + icon: 'xai', + color: 'linear-gradient(135deg, #111, #444)', + match: (context) => includesAny(context, ['grok']), + }, + { + name: '阶跃星辰', + icon: 'stepfun-color', + color: 'linear-gradient(135deg, #0066ff, #3399ff)', + match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']), + }, + { + name: 'Stability', + icon: 'stability-color', + color: 'linear-gradient(135deg, #8b5cf6, #a855f7)', + match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']), + }, + { + name: 'NVIDIA', + icon: 'nvidia-color', + color: 'linear-gradient(135deg, #76b900, #4a8c0b)', + match: (context) => ( + includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos']) + || startsWithAny(context, ['nv-']) + ), + }, + { + name: 'IBM', + icon: 'ibm', + color: 'linear-gradient(135deg, #0f62fe, #4589ff)', + match: (context) => includesAny(context, ['ibm/', 'granite']), + }, + { + name: 'BAAI', + icon: 'baai', + color: 'linear-gradient(135deg, #111827, #374151)', + match: (context) => includesAny(context, ['baai/', 'bge-']), + }, + { + name: 'ByteDance', + icon: 'bytedance-color', + color: 'linear-gradient(135deg, #325ab4, #0f66ff)', + match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']), + }, + { + name: 'InternLM', + icon: 'internlm-color', + color: 'linear-gradient(135deg, #1b3882, #4063c5)', + match: (context) => includesAny(context, ['internlm']), + }, + { + name: 'Midjourney', + icon: 'midjourney', + color: 'linear-gradient(135deg, #4c6ef5, #748ffc)', + match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']), + }, + { + name: 'DeepL', + icon: 'deepl-color', + color: 'linear-gradient(135deg, #0f2b46, #21476f)', + match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']), + }, + { + name: 'Jina AI', + icon: 'jina', + color: 'linear-gradient(135deg, #111827, #4b5563)', + match: (context) => includesAny(context, ['jina']), + }, ]; function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -const BRAND_RULES = BRAND_MAP.map((entry) => ({ - brand: entry.brand, - prefixMatchers: entry.prefixes.map((prefix) => ({ - prefix, - boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(prefix)}(?=$|[^a-z0-9])`), - })), +const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({ + brand, + boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`), })); -function collectBrandCandidates(modelName: string): string[] { - const queue: string[] = []; - const seen = new Set(); - const push = (value: string) => { - const normalized = (value || '').trim().toLowerCase(); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - queue.push(normalized); - }; +export function normalizeBrandIconKey(icon: string | null | undefined): string | null { + const normalized = normalizeInput(icon).replace(/\./g, '-'); + if (!normalized) return null; + return LEGACY_ICON_ALIASES[normalized] || normalized; +} - push(modelName); - for (let i = 0; i < queue.length; i += 1) { - const candidate = queue[i]; - - // Strip common route/model wrappers, e.g. "[Summer] gpt-5.2". - push(candidate.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')); - - if (candidate.startsWith('re:')) { - push(candidate.slice(3).trim()); - } - push(candidate.replace(/^\^+/, '').replace(/\$+$/, '')); - - if (candidate.includes('/')) { - for (const part of candidate.split('/')) push(part); - } - if (candidate.includes(':')) { - for (const part of candidate.split(':')) push(part); - } - } - - return queue; +export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null { + const normalized = normalizeBrandIconKey(icon); + if (!normalized) return null; + return `${cdn}/${normalized}.png`; } export function getBrand(modelName: string): BrandInfo | null { - const candidates = collectBrandCandidates(modelName); + const context = buildMatchContext(modelName); - // Prefer strict prefix matching first for predictable behavior. - for (const candidate of candidates) { - for (const rule of BRAND_RULES) { - for (const matcher of rule.prefixMatchers) { - if (candidate.startsWith(matcher.prefix)) return rule.brand; - } - } + for (const definition of BRAND_DEFINITIONS) { + if (definition.match(context)) { + return { + name: definition.name, + icon: definition.icon, + color: definition.color, + }; } + } - // Fallback for regex/model wrappers where brand token appears inside the pattern. - for (const candidate of candidates) { - for (const rule of BRAND_RULES) { - for (const matcher of rule.prefixMatchers) { - if (matcher.boundaryRegex.test(candidate)) return rule.brand; - } - } + for (const candidate of context.candidates) { + for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) { + if (rule.boundaryRegex.test(candidate)) { + return { + name: rule.brand.name, + icon: rule.brand.icon, + color: rule.brand.color, + }; + } } + } - return null; + return null; } - -const FALLBACK_COLORS = [ - 'linear-gradient(135deg, #4f46e5, #818cf8)', - 'linear-gradient(135deg, #059669, #34d399)', - 'linear-gradient(135deg, #2563eb, #60a5fa)', - 'linear-gradient(135deg, #d946ef, #f0abfc)', - 'linear-gradient(135deg, #ea580c, #fb923c)', - 'linear-gradient(135deg, #0891b2, #22d3ee)', - 'linear-gradient(135deg, #7c3aed, #a78bfa)', - 'linear-gradient(135deg, #dc2626, #f87171)', -]; - -export function hashColor(name: string): string { - let h = 0; - for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0; - return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; -} - + +const FALLBACK_COLORS = [ + 'linear-gradient(135deg, #4f46e5, #818cf8)', + 'linear-gradient(135deg, #059669, #34d399)', + 'linear-gradient(135deg, #2563eb, #60a5fa)', + 'linear-gradient(135deg, #d946ef, #f0abfc)', + 'linear-gradient(135deg, #ea580c, #fb923c)', + 'linear-gradient(135deg, #0891b2, #22d3ee)', + 'linear-gradient(135deg, #7c3aed, #a78bfa)', + 'linear-gradient(135deg, #dc2626, #f87171)', +]; + +export function hashColor(name: string): string { + let h = 0; + for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0; + return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; +} + function avatarLetters(name: string): string { - const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/); - if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); - return name.slice(0, 2).toUpperCase(); -} - + const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +type BrandGlyphProps = { + brand?: Pick | null; + model?: string | null; + icon?: string | null; + alt?: string; + size?: number; + fallbackText?: string | null; + style?: CSSProperties; +}; + +export function BrandGlyph({ brand, model, icon, alt, size = 16, fallbackText, style }: BrandGlyphProps) { + const cdn = useIconCdn(); + const resolvedBrand = brand || (model ? getBrand(model) : null); + const resolvedIcon = normalizeBrandIconKey(icon || resolvedBrand?.icon || null); + const [imgError, setImgError] = useState(false); + + useEffect(() => { + setImgError(false); + }, [resolvedIcon]); + + if (resolvedIcon && !imgError) { + const src = getBrandIconUrl(resolvedIcon, cdn); + if (src) { + return ( + {alt setImgError(true)} + loading="lazy" + /> + ); + } + } + + const fallback = (fallbackText ?? resolvedBrand?.name ?? model ?? '').trim(); + if (!fallback) return null; + + return ( + + ); +} + export function BrandIcon({ model, size = 44 }: { model: string; size?: number }) { - const brand = getBrand(model); - const [imgError, setImgError] = useState(false); - - if (brand && !imgError) { - return ( -
- {brand.name} setImgError(true)} - loading="lazy" - /> -
- ); - } - - return ( -
32 ? 16 : 10 }}> - {avatarLetters(model)} -
- ); -} - + const brand = getBrand(model); + + if (brand) { + return ( +
+ +
+ ); + } + + return ( +
32 ? 16 : 10 }}> + {avatarLetters(model)} +
+ ); +} + export function InlineBrandIcon({ model, size = 16 }: { model: string; size?: number }) { - const brand = getBrand(model); - const [imgError, setImgError] = useState(false); - const cdn = useIconCdn(); - - if (brand && !imgError) { - return ( - {brand.name} setImgError(true)} - loading="lazy" - /> - ); - } - - return null; -} - -export function ModelBadge({ model, style }: { model: string; style?: React.CSSProperties }) { - const brand = getBrand(model); - - // Brand-specific colors for badge background - const badgeColors: Record = { - 'OpenAI': { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' }, - 'Anthropic': { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' }, - 'Google': { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' }, - 'DeepSeek': { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' }, - 'xAI': { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' }, - }; - - const brandName = brand?.name || ''; - const colors = badgeColors[brandName] || { - bg: 'var(--color-primary-light)', - border: 'rgba(79,70,229,0.15)', - text: 'var(--color-primary)', - }; - - return ( - - - {model} - - ); -} + const brand = getBrand(model); + if (!brand) return null; + return ; +} + +export function ModelBadge({ model, style }: { model: string; style?: CSSProperties }) { + const brand = getBrand(model); + + const badgeColors: Record = { + OpenAI: { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' }, + Anthropic: { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' }, + Google: { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' }, + DeepSeek: { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' }, + 'Jina AI': { bg: 'rgba(17,24,39,0.08)', border: 'rgba(17,24,39,0.16)', text: '#111827' }, + Microsoft: { bg: 'rgba(0,164,239,0.08)', border: 'rgba(0,164,239,0.18)', text: '#0f62fe' }, + NVIDIA: { bg: 'rgba(118,185,0,0.10)', border: 'rgba(118,185,0,0.18)', text: '#4a8c0b' }, + xAI: { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' }, + }; + + const brandName = brand?.name || ''; + const colors = badgeColors[brandName] || { + bg: 'var(--color-primary-light)', + border: 'rgba(79,70,229,0.15)', + text: 'var(--color-primary)', + }; + + return ( + + + {model} + + ); +} diff --git a/src/web/components/ModelAnalysisPanel.test.tsx b/src/web/components/ModelAnalysisPanel.test.tsx new file mode 100644 index 0000000..22eb0c0 --- /dev/null +++ b/src/web/components/ModelAnalysisPanel.test.tsx @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create, type ReactTestInstance } from 'react-test-renderer'; +import ModelAnalysisPanel from './ModelAnalysisPanel.js'; + +vi.mock('@visactor/react-vchart', () => ({ + VChart: () =>
mock-chart
, +})); + +vi.mock('./BrandIcon.js', () => ({ + InlineBrandIcon: () => brand-icon, +})); + +function collectText(node: ReactTestInstance): string { + return (node.children || []).map((child) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +describe('ModelAnalysisPanel token summaries', () => { + const originalDocument = globalThis.document; + const originalGetComputedStyle = globalThis.getComputedStyle; + const originalMutationObserver = globalThis.MutationObserver; + + beforeEach(() => { + globalThis.document = { + documentElement: {}, + } as unknown as Document; + globalThis.getComputedStyle = vi.fn(() => ({ + getPropertyValue: () => '#9ca3af', + })) as unknown as typeof getComputedStyle; + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + } as unknown as typeof MutationObserver; + }); + + afterEach(() => { + globalThis.document = originalDocument; + globalThis.getComputedStyle = originalGetComputedStyle; + globalThis.MutationObserver = originalMutationObserver; + }); + + it('renders total token summaries with compact units', () => { + let root: ReturnType | null = null; + + act(() => { + root = create( + , + ); + }); + + const rendered = collectText(root!.root); + + expect(rendered).toContain('总 Tokens'); + expect(rendered).toContain('611.5M'); + + root?.unmount(); + }); +}); diff --git a/src/web/components/ModelAnalysisPanel.tsx b/src/web/components/ModelAnalysisPanel.tsx index e5c7540..70f31be 100644 --- a/src/web/components/ModelAnalysisPanel.tsx +++ b/src/web/components/ModelAnalysisPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { VChart } from '@visactor/react-vchart'; import { InlineBrandIcon } from './BrandIcon.js'; +import { formatCompactTokenMetric } from '../numberFormat.js'; type TabKey = 'spend' | 'trend' | 'calls' | 'rank'; @@ -135,7 +136,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
总 Tokens
-
{Math.round(totals.tokens).toLocaleString()}
+
{formatCompactTokenMetric(totals.tokens)}
diff --git a/src/web/components/ModernSelect.test.tsx b/src/web/components/ModernSelect.test.tsx new file mode 100644 index 0000000..80f0807 --- /dev/null +++ b/src/web/components/ModernSelect.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { create } from 'react-test-renderer'; +import ModernSelect from './ModernSelect.js'; + +function collectText(node: ReturnType['root']): string { + return node.findAll(() => true) + .flatMap((instance) => instance.children) + .filter((child): child is string => typeof child === 'string') + .join(''); +} + +describe('ModernSelect', () => { + it('renders icon nodes for the selected option', () => { + const root = create( + {}} + options={[ + { + value: 'nvidia', + label: 'NVIDIA', + description: 'NVIDIA 品牌图标', + iconNode: 🟢, + } as any, + ]} + />, + ); + + expect(collectText(root.root)).toContain('🟢'); + expect(collectText(root.root)).toContain('NVIDIA'); + }); +}); diff --git a/src/web/components/ModernSelect.tsx b/src/web/components/ModernSelect.tsx index 603b996..49f7183 100644 --- a/src/web/components/ModernSelect.tsx +++ b/src/web/components/ModernSelect.tsx @@ -1,71 +1,76 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; + type ModernSelectOption = { value: string; label: string; description?: string; disabled?: boolean; + iconNode?: ReactNode; iconUrl?: string; iconText?: string; }; - -type ModernSelectProps = { - value: string; - onChange: (value: string) => void; - options: ModernSelectOption[]; - placeholder?: string; - disabled?: boolean; - emptyLabel?: string; - menuMaxHeight?: number; - className?: string; - size?: 'md' | 'sm'; -}; - + +type ModernSelectProps = { + value: string; + onChange: (value: string) => void; + options: ModernSelectOption[]; + placeholder?: string; + disabled?: boolean; + emptyLabel?: string; + menuMaxHeight?: number; + className?: string; + size?: 'md' | 'sm'; +}; + export default function ModernSelect({ - value, - onChange, - options, - placeholder = 'Select', - disabled = false, - emptyLabel = 'No options', - menuMaxHeight = 280, - className = '', - size = 'md', + value, + onChange, + options, + placeholder = 'Select', + disabled = false, + emptyLabel = 'No options', + menuMaxHeight = 280, + className = '', + size = 'md', }: ModernSelectProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); - - const selected = useMemo( - () => options.find((item) => item.value === value), - [options, value], - ); - - useEffect(() => { - if (!open) return; - - const handleOutsideClick = (event: MouseEvent) => { - if (!rootRef.current) return; - if (!rootRef.current.contains(event.target as Node)) { - setOpen(false); - } - }; - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') setOpen(false); - }; - - document.addEventListener('mousedown', handleOutsideClick); - document.addEventListener('keydown', handleEscape); - return () => { - document.removeEventListener('mousedown', handleOutsideClick); - document.removeEventListener('keydown', handleEscape); - }; - }, [open]); - + + const selected = useMemo( + () => options.find((item) => item.value === value), + [options, value], + ); + + useEffect(() => { + if (!open) return; + + const handleOutsideClick = (event: MouseEvent) => { + if (!rootRef.current) return; + if (!rootRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + + document.addEventListener('mousedown', handleOutsideClick); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleOutsideClick); + document.removeEventListener('keydown', handleEscape); + }; + }, [open]); + useEffect(() => { if (disabled) setOpen(false); }, [disabled]); const renderOptionIcon = (item: ModernSelectOption) => { + if (item.iconNode) { + return item.iconNode; + } if (item.iconUrl) { return ; } @@ -76,18 +81,18 @@ export default function ModernSelect({ }; return ( -
- - -
- {options.length === 0 ? ( -
{emptyLabel}
- ) : ( - options.map((item) => { - const active = item.value === value; - return ( - + +
+ {options.length === 0 ? ( +
{emptyLabel}
+ ) : ( + options.map((item) => { + const active = item.value === value; + return ( +
); } - diff --git a/src/web/index.css b/src/web/index.css index b15da137..aa343f6 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -1110,6 +1110,119 @@ body { letter-spacing: -0.02em; } +.dashboard-stat-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.dashboard-stat-grid .stat-card { + min-width: 0; +} + +.dashboard-stat-grid .stat-card-row { + min-width: 0; +} + +.dashboard-stat-content { + min-width: 0; +} + +.dashboard-stat-grid .stat-label, +.dashboard-stat-grid .stat-value, +.dashboard-stat-note { + white-space: nowrap; +} + +.dashboard-stat-grid .stat-label { + font-size: clamp(11px, 0.85vw, 12px); +} + +.dashboard-stat-grid .stat-value { + font-size: clamp(14px, 1.25vw, 18px); +} + +.dashboard-stat-note { + font-size: 11px; + color: var(--color-text-muted); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (max-width: 1440px) { + .dashboard-stat-grid { + gap: 12px; + } + + .dashboard-stat-grid .stat-card { + padding: 16px; + } +} + +@media (max-width: 1200px) { + .dashboard-stat-grid { + gap: 10px; + } + + .dashboard-stat-grid .stat-card { + padding: 14px; + } + + .dashboard-stat-grid .stat-icon { + width: 30px; + height: 30px; + } + + .dashboard-stat-grid .stat-card-row { + gap: 10px; + } + + .dashboard-stat-grid .stat-card-header { + margin-bottom: 14px; + } +} + +@media (max-width: 1024px) { + .dashboard-stat-grid { + gap: 8px; + } + + .dashboard-stat-grid .stat-card { + padding: 12px; + } + + .dashboard-stat-grid .stat-icon { + width: 24px; + height: 24px; + font-size: 12px; + } + + .dashboard-stat-grid .stat-card-row { + gap: 8px; + } + + .dashboard-stat-grid .stat-card-header { + gap: 6px; + margin-bottom: 12px; + font-size: 12px; + } + + .dashboard-stat-grid .stat-card-header svg { + width: 14px; + height: 14px; + } + + .dashboard-stat-grid .stat-value { + font-size: clamp(12px, 1.4vw, 16px); + } + + .dashboard-stat-note { + font-size: 10px; + } +} + /* ===== Nav ===== */ .nav-transition { transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); diff --git a/src/web/numberFormat.test.ts b/src/web/numberFormat.test.ts new file mode 100644 index 0000000..0d15a3e --- /dev/null +++ b/src/web/numberFormat.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { formatCompactTokenMetric } from './numberFormat.js'; + +describe('formatCompactTokenMetric', () => { + it('keeps small values uncompressed', () => { + expect(formatCompactTokenMetric(950)).toBe('950'); + }); + + it('formats thousands with K suffix', () => { + expect(formatCompactTokenMetric(7_974)).toBe('8K'); + }); + + it('formats millions with M suffix', () => { + expect(formatCompactTokenMetric(1_800_000)).toBe('1.8M'); + expect(formatCompactTokenMetric(611_540_335)).toBe('611.5M'); + }); +}); diff --git a/src/web/numberFormat.ts b/src/web/numberFormat.ts new file mode 100644 index 0000000..9f7c14b --- /dev/null +++ b/src/web/numberFormat.ts @@ -0,0 +1,27 @@ +function toSafeNumber(value: unknown): number { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0; + return value; +} + +function trimTrailingZero(value: string): string { + return value.replace(/\.0$/, ''); +} + +export function formatCompactTokenMetric(value: number): string { + const normalized = Math.round(toSafeNumber(value)); + const absoluteValue = Math.abs(normalized); + + if (absoluteValue >= 1_000_000_000) { + return `${trimTrailingZero((normalized / 1_000_000_000).toFixed(1))}B`; + } + + if (absoluteValue >= 1_000_000) { + return `${trimTrailingZero((normalized / 1_000_000).toFixed(1))}M`; + } + + if (absoluteValue >= 1_000) { + return `${trimTrailingZero((normalized / 1_000).toFixed(1))}K`; + } + + return normalized.toLocaleString(); +} diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index a799bb8..2877606 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -197,18 +197,19 @@ export default function Accounts() { }, [activeSegment]); useEffect(() => { - if (activeSegment !== 'apikey' || !loaded) return; + if (activeSegment === 'tokens' || !loaded) return; const params = new URLSearchParams(location.search); const shouldOpenCreate = isTruthyFlag(params.get('create')); const requestedSiteId = parsePositiveInt(params.get('siteId')); if (!shouldOpenCreate || !requestedSiteId) return; + const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session'; setShowAdd(true); setAddMode('token'); setVerifyResult(null); setLoginForm(createLoginForm()); setTokenForm({ - ...createTokenForm('apikey'), + ...createTokenForm(credentialMode), siteId: requestedSiteId, }); @@ -1013,7 +1014,7 @@ export default function Accounts() { ) : (
- API Key 连接只用于代理转发,不会自动派生账号令牌。站点保存后也会跳到这里继续补充首个 API Key。 + API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
{activeSegment === 'apikey' ? '暂无 API Key 连接' : '暂无 Session 连接'}
- {activeSegment === 'apikey' ? '请先添加站点,然后为站点补充 API Key 连接' : '请先添加站点,然后添加 Session 连接'} + {activeSegment === 'apikey' + ? (sites.length > 0 ? '请为现有站点补充 API Key 连接' : '请先添加站点,然后为站点补充 API Key 连接') + : (sites.length > 0 ? '请为现有站点添加 Session 连接' : '请先添加站点,然后添加 Session 连接')}
)} diff --git a/src/web/pages/Dashboard.tsx b/src/web/pages/Dashboard.tsx index b080878..5a3779f 100644 --- a/src/web/pages/Dashboard.tsx +++ b/src/web/pages/Dashboard.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy, useEffect, useState, useCallback } from 'react'; import { api } from '../api.js'; import { useToast } from '../components/Toast.js'; +import { formatCompactTokenMetric } from '../numberFormat.js'; const ModelAnalysisPanel = lazy(() => import('../components/ModelAnalysisPanel.js')); const SiteDistributionChart = lazy(() => import('../components/charts/SiteDistributionChart.js')); @@ -153,8 +154,8 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN return (
-
- {[...Array(4)].map((_, i) => ( +
+ {[...Array(5)].map((_, i) => (
@@ -209,6 +210,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN const proxy24hSuccess = safeNumber(data?.proxy24h?.success); const proxy24hTotal = safeNumber(data?.proxy24h?.total); const totalTokens = safeNumber(data?.proxy24h?.totalTokens); + const performanceWindowSeconds = Math.max(1, safeNumber(data?.performance?.windowSeconds) || 60); + const requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute); + const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute); const getLatencyColor = (ms: number) => ( ms <= 500 @@ -272,7 +276,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
@@ -282,7 +286,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
当前余额
${totalBalance.toFixed(2)}
-
+
累计消耗
${totalUsed.toFixed(2)}
-
+
24h 请求
{Math.round(proxy24hTotal).toLocaleString()}
@@ -336,7 +340,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
成功请求
{Math.round(proxy24hSuccess).toLocaleString()}
@@ -352,7 +356,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
活跃账户
{Math.round(activeAccounts)}/{Math.round(totalAccounts)}
@@ -361,9 +365,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
24h Tokens
-
{Math.round(totalTokens).toLocaleString()}
+
{formatCompactTokenMetric(totalTokens)}
@@ -377,7 +381,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
今日签到
{Math.round(todaySuccess)}/{Math.round(todayTotal)}
@@ -386,7 +390,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
成功率
{todayTotal > 0 ? Math.round((todaySuccess / todayTotal) * 100) : 0}% @@ -394,6 +398,33 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
+ +
+
+ + 性能指标 +
+
+
+ +
+
+
RPM
+
{Math.round(requestsPerMinute).toLocaleString()}
+
最近 {performanceWindowSeconds} 秒请求
+
+
+
+
+ +
+
+
TPM
+
{formatCompactTokenMetric(tokensPerMinute)}
+
最近 {performanceWindowSeconds} 秒 Tokens
+
+
+
{/* 站点级分析 */} @@ -418,12 +449,12 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
}>
-
+
}> @@ -431,7 +462,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
@@ -443,7 +474,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index ba44f87..9af8b71 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -4,10 +4,18 @@ import { clearAuthSession, getAuthToken } from '../authSession.js'; import { DEBUG_TABS, DEFAULT_INPUTS, + DEFAULT_MODE_STATE, DEFAULT_PARAMETER_ENABLED, MODEL_TESTER_STORAGE_KEY, MESSAGE_STATUS, buildApiPayload, + buildEmbeddingsRequestEnvelope, + buildImagesEditRequestEnvelope, + buildImagesGenerationsRequestEnvelope, + buildRawProxyRequestEnvelope, + buildSearchRequestEnvelope, + buildVideoCreateRequestEnvelope, + buildVideoInspectRequestEnvelope, countConversationTurns, collectModelTesterModelNames, createLoadingAssistantMessage, @@ -24,7 +32,10 @@ import { type ChatMessage, type DebugTab, type ModelTesterInputs, + type ModelTesterModeState, type ParameterEnabled, + type PlaygroundMode, + type PlaygroundMultipartFile, type TestTargetFormat, type TestChatPayload, } from './helpers/modelTesterSession.js'; @@ -45,9 +56,47 @@ type DebugTimelineEntry = { text: string; }; +type UploadState = { + name: string; + mimeType: string; + dataUrl: string; +}; + const POLL_INTERVAL_MS = 1200; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const fileToDataUrl = async (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error('read file failed')); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.readAsDataURL(file); + }); + +const summarizeModeRequest = ( + mode: PlaygroundMode, + input: string, + modeState: ModelTesterModeState, + videoAction: 'get' | 'delete', +): string => { + if (mode === 'embeddings') return input.trim() || modeState.embeddingsInput.trim() || 'Embedding request'; + if (mode === 'search') return input.trim() || modeState.searchQuery.trim() || 'Search request'; + if (mode === 'images.generate' || mode === 'images.edit') return input.trim() || modeState.imagesPrompt.trim() || 'Image request'; + if (mode === 'videos.create') return input.trim() || modeState.videosPrompt.trim() || 'Video request'; + if (mode === 'videos.inspect') { + const id = input.trim() || modeState.videosInspectId.trim(); + return `${videoAction.toUpperCase()} ${id || 'video'}`; + } + return input.trim(); +}; + +const readFileAsDataUrl = (file: File): Promise => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(reader.error || new Error('读取文件失败')); + reader.readAsDataURL(file); +}); + const formatJson = (value: unknown): string => { if (typeof value === 'string') { try { @@ -518,6 +567,29 @@ const toNumber = (value: string, fallback: number): number => { return Number.isFinite(parsed) ? parsed : fallback; }; +const splitCsvOrLines = (value: string): string[] => + value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); + +const CONVERSATION_MODE_OPTIONS: Array<{ value: PlaygroundMode; label: string }> = [ + { value: 'conversation', label: '对话' }, + { value: 'embeddings', label: 'Embeddings' }, + { value: 'search', label: 'Search' }, + { value: 'images.generate', label: '图片生成' }, + { value: 'images.edit', label: '图片编辑' }, + { value: 'videos.create', label: '视频创建' }, + { value: 'videos.inspect', label: '视频查询/删除' }, +]; + +const PROTOCOL_OPTIONS: Array<{ value: PlaygroundProtocol; label: string }> = [ + { value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, + { value: 'responses', label: 'OpenAI Responses (/v1/responses)' }, + { value: 'claude', label: 'Claude (/v1/messages)' }, + { value: 'gemini', label: 'Gemini Native (/gemini/v1beta/models/*)' }, +]; + const inputBaseStyle: React.CSSProperties = { width: '100%', padding: '10px 14px', @@ -568,6 +640,7 @@ export default function ModelTester() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [inputs, setInputs] = useState(DEFAULT_INPUTS); + const [modeState, setModeState] = useState(DEFAULT_MODE_STATE); const [parameterEnabled, setParameterEnabled] = useState(DEFAULT_PARAMETER_ENABLED); const [sending, setSending] = useState(false); @@ -586,10 +659,21 @@ export default function ModelTester() { const [debugPreview, setDebugPreview] = useState(''); const [debugTimeline, setDebugTimeline] = useState([]); const [debugTimestamp, setDebugTimestamp] = useState(''); + const [nonConversationResult, setNonConversationResult] = useState(null); + + const [searchQueryValue, setSearchQueryValue] = useState(''); + const [searchAllowedDomains, setSearchAllowedDomains] = useState(''); + const [searchBlockedDomains, setSearchBlockedDomains] = useState(''); + const [searchMaxResults, setSearchMaxResults] = useState(10); + const [embeddingInputText, setEmbeddingInputText] = useState(''); + const [assetPrompt, setAssetPrompt] = useState(''); + const [videoInspectId, setVideoInspectId] = useState(''); + const [videoInspectAction, setVideoInspectAction] = useState<'GET' | 'DELETE'>('GET'); + const [imageSourceFile, setImageSourceFile] = useState(null); + const [imageMaskFile, setImageMaskFile] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); const [editValue, setEditValue] = useState(''); - const chatEndRef = useRef(null); const restoredSessionRef = useRef>(null); const streamAbortRef = useRef(null); @@ -605,7 +689,26 @@ export default function ModelTester() { }, []); const updateInput = useCallback((key: K, value: ModelTesterInputs[K]) => { - setInputs((prev) => ({ ...prev, [key]: value })); + setInputs((prev) => { + if (key === 'protocol') { + return { + ...prev, + protocol: value as ModelTesterInputs['protocol'], + }; + } + return { ...prev, [key]: value }; + }); + }, []); + + const updateModeState = useCallback((key: K, value: ModelTesterModeState[K]) => { + setModeState((prev) => ({ ...prev, [key]: value })); + }, []); + + const updateProtocol = useCallback((protocol: PlaygroundProtocol) => { + setInputs((prev) => ({ + ...prev, + protocol, + })); }, []); const toggleParameter = useCallback((key: keyof ParameterEnabled) => { @@ -620,6 +723,7 @@ export default function ModelTester() { setMessages(restored.messages); setInput(restored.input); setInputs(restored.inputs); + setModeState(restored.modeState); setParameterEnabled(restored.parameterEnabled); setPendingPayload(restored.pendingPayload); setPendingJobId(restored.pendingJobId || null); @@ -627,6 +731,13 @@ export default function ModelTester() { setCustomRequestBody(restored.customRequestBody); setShowDebugPanel(restored.showDebugPanel); setActiveDebugTab(restored.activeDebugTab); + setEmbeddingInputText(restored.modeState.embeddingsInput); + setSearchQueryValue(restored.modeState.searchQuery); + setSearchAllowedDomains(restored.modeState.searchAllowedDomains); + setSearchBlockedDomains(restored.modeState.searchBlockedDomains); + setAssetPrompt(restored.modeState.imagesPrompt || restored.modeState.videosPrompt); + setVideoInspectId(restored.modeState.videosInspectId); + setVideoInspectAction(restored.inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET'); if (restored.pendingJobId) { setSending(true); @@ -687,6 +798,17 @@ export default function ModelTester() { inputs, parameterEnabled, messages, + modeState: { + embeddingsInput: embeddingInputText, + searchQuery: searchQueryValue, + searchAllowedDomains, + searchBlockedDomains, + imagesPrompt: inputs.mode === 'images.generate' || inputs.mode === 'images.edit' ? assetPrompt : '', + imagesMaskDataUrl: imageMaskFile?.dataUrl || '', + videosPrompt: inputs.mode === 'videos.create' ? assetPrompt : '', + videosInspectId: videoInspectId, + extraJson: customRequestBody, + }, pendingPayload, pendingJobId, customRequestMode, @@ -701,13 +823,341 @@ export default function ModelTester() { input, inputs, messages, + assetPrompt, + customRequestBody, + embeddingInputText, + imageMaskFile?.dataUrl, parameterEnabled, pendingJobId, pendingPayload, + searchAllowedDomains, + searchBlockedDomains, + searchQueryValue, showDebugPanel, + videoInspectId, ]); + const handleUploadChange = useCallback(async ( + fileList: FileList | null, + setter: React.Dispatch>, + ) => { + const file = fileList?.[0]; + if (!file) { + setter(null); + return; + } + + try { + const dataUrl = await readFileAsDataUrl(file); + setter({ + name: file.name, + mimeType: file.type || 'application/octet-stream', + dataUrl, + }); + } catch (readError: any) { + setError(readError?.message || '读取文件失败'); + } + }, []); + + const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => { + if (!inputs.systemPrompt.trim()) return baseMessages; + return [ + createMessage('system', inputs.systemPrompt.trim()), + ...baseMessages, + ]; + }, [inputs.systemPrompt]); + + const buildClaudeBodyFromMessages = 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 downstreamMessages = effectiveMessages + .filter((message) => message.role !== 'system' && message.role !== 'developer') + .map((message) => ({ + role: message.role === 'assistant' ? 'assistant' : 'user', + content: message.content, + })); + + return { + model: inputs.model, + stream: inputs.stream, + max_tokens: parameterEnabled.max_tokens ? inputs.max_tokens : DEFAULT_INPUTS.max_tokens, + ...(systemContents.length > 0 ? { system: systemContents.join('\n\n') } : {}), + ...(parameterEnabled.temperature ? { temperature: inputs.temperature } : {}), + ...(parameterEnabled.top_p ? { top_p: inputs.top_p } : {}), + messages: downstreamMessages, + }; + }, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.model, inputs.stream, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]); + + const buildResponsesBodyFromMessages = 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 downstreamMessages = effectiveMessages + .filter((message) => message.role !== 'system' && message.role !== 'developer') + .map((message) => ({ + role: message.role === 'assistant' ? 'assistant' : 'user', + content: message.content, + })); + + return { + model: inputs.model, + stream: inputs.stream, + ...(parameterEnabled.temperature ? { temperature: inputs.temperature } : {}), + ...(parameterEnabled.top_p ? { top_p: inputs.top_p } : {}), + ...(parameterEnabled.max_tokens ? { max_output_tokens: inputs.max_tokens } : {}), + ...(systemContents.length > 0 ? { instructions: systemContents.join('\n\n') } : {}), + input: downstreamMessages.length === 1 && downstreamMessages[0].role === 'user' && systemContents.length === 0 + ? downstreamMessages[0].content + : downstreamMessages, + }; + }, [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 normalizedMessages = buildConversationMessagesWithSystem(baseMessages); + + if (customRequestMode) { + const path = inputs.protocol === 'gemini' + ? `/gemini/v1beta/models/${encodeURIComponent(inputs.model)}:generateContent${inputs.stream ? '?alt=sse' : ''}` + : inputs.protocol === 'claude' + ? '/v1/messages' + : inputs.protocol === 'responses' + ? '/v1/responses' + : '/v1/chat/completions'; + + return { + method: 'POST', + path, + requestKind: 'json', + stream: inputs.stream, + jobMode: false, + rawMode: true, + rawJsonText: customRequestBody, + }; + } + + if (inputs.protocol === 'claude') { + return { + method: 'POST', + path: '/v1/messages', + requestKind: 'json', + stream: inputs.stream, + jobMode: false, + rawMode: false, + jsonBody: buildClaudeBodyFromMessages(baseMessages), + }; + } + + if (inputs.protocol === 'responses') { + return { + method: 'POST', + path: '/v1/responses', + requestKind: 'json', + stream: inputs.stream, + jobMode: false, + rawMode: false, + jsonBody: buildResponsesBodyFromMessages(baseMessages), + }; + } + + if (inputs.protocol === 'gemini') { + return { + 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 openAiPayload = (openAiEnvelope.jsonBody && typeof openAiEnvelope.jsonBody === 'object') + ? { ...(openAiEnvelope.jsonBody as Record) } + : {}; + + return { + method: 'POST', + path: '/v1/chat/completions', + requestKind: 'json', + stream: inputs.stream, + jobMode: false, + rawMode: false, + jsonBody: openAiPayload, + }; + }, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildGeminiBodyFromMessages, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]); + + const buildModeProxyEnvelope = useCallback((): ProxyTestEnvelope | null => { + if (inputs.mode === 'embeddings') { + const trimmed = embeddingInputText.trim(); + if (!trimmed) return null; + return { + method: 'POST', + path: '/v1/embeddings', + requestKind: 'json', + stream: false, + jobMode: false, + rawMode: customRequestMode, + ...(customRequestMode + ? { rawJsonText: customRequestBody } + : { jsonBody: { model: inputs.model, input: trimmed } }), + }; + } + + if (inputs.mode === 'search') { + if (!searchQueryValue.trim()) return null; + return { + method: 'POST', + path: '/v1/search', + requestKind: 'json', + stream: false, + jobMode: false, + rawMode: customRequestMode, + ...(customRequestMode + ? { rawJsonText: customRequestBody } + : { + jsonBody: { + model: inputs.model || '__search', + query: searchQueryValue.trim(), + max_results: Math.max(1, Math.min(20, Math.trunc(searchMaxResults || 10))), + ...(splitCsvOrLines(searchAllowedDomains).length > 0 ? { allowed_domains: splitCsvOrLines(searchAllowedDomains) } : {}), + ...(splitCsvOrLines(searchBlockedDomains).length > 0 ? { blocked_domains: splitCsvOrLines(searchBlockedDomains) } : {}), + }, + }), + }; + } + + if (inputs.mode === 'images.generate') { + if (!assetPrompt.trim()) return null; + return { + method: 'POST', + path: '/v1/images/generations', + requestKind: 'json', + stream: false, + jobMode: false, + rawMode: customRequestMode, + ...(customRequestMode + ? { rawJsonText: customRequestBody } + : { jsonBody: { model: inputs.model, prompt: assetPrompt.trim() } }), + }; + } + + if (inputs.mode === 'images.edit') { + if (!assetPrompt.trim() || !imageSourceFile) return null; + return { + method: 'POST', + path: '/v1/images/edits', + requestKind: 'multipart', + stream: false, + jobMode: false, + rawMode: false, + multipartFields: { + model: inputs.model, + prompt: assetPrompt.trim(), + }, + multipartFiles: [ + { + field: 'image', + name: imageSourceFile.name, + mimeType: imageSourceFile.mimeType, + dataUrl: imageSourceFile.dataUrl, + }, + ...(imageMaskFile ? [{ + field: 'mask', + name: imageMaskFile.name, + mimeType: imageMaskFile.mimeType, + dataUrl: imageMaskFile.dataUrl, + }] : []), + ], + }; + } + + if (inputs.mode === 'videos.create') { + if (!assetPrompt.trim()) return null; + if (imageSourceFile) { + return { + method: 'POST', + path: '/v1/videos', + requestKind: 'multipart', + stream: false, + jobMode: false, + rawMode: false, + multipartFields: { + model: inputs.model, + prompt: assetPrompt.trim(), + }, + multipartFiles: [ + { + field: 'input_reference', + name: imageSourceFile.name, + mimeType: imageSourceFile.mimeType, + dataUrl: imageSourceFile.dataUrl, + }, + ], + }; + } + + return { + method: 'POST', + path: '/v1/videos', + requestKind: 'json', + stream: false, + jobMode: false, + rawMode: customRequestMode, + ...(customRequestMode + ? { rawJsonText: customRequestBody } + : { jsonBody: { model: inputs.model, prompt: assetPrompt.trim() } }), + }; + } + + if (inputs.mode === 'videos.inspect') { + if (!videoInspectId.trim()) return null; + return { + method: videoInspectAction, + path: `/v1/videos/${encodeURIComponent(videoInspectId.trim())}`, + requestKind: 'empty', + stream: false, + jobMode: false, + rawMode: false, + }; + } + + return null; + }, [assetPrompt, customRequestBody, customRequestMode, embeddingInputText, imageMaskFile, imageSourceFile, inputs.mode, inputs.model, searchAllowedDomains, searchBlockedDomains, searchMaxResults, searchQueryValue, videoInspectAction, videoInspectId]); + const previewPayload = useMemo(() => { + if (inputs.mode !== 'conversation') { + return buildModeProxyEnvelope(); + } if (customRequestMode) { const raw = customRequestBody.trim(); if (!raw) return null; @@ -717,15 +1167,18 @@ export default function ModelTester() { return { _error: '自定义请求体中的 JSON 无效', raw }; } } - return buildApiPayload(messages, inputs, parameterEnabled); - }, [customRequestBody, customRequestMode, inputs, messages, parameterEnabled]); + if (inputs.protocol === 'gemini') { + return buildConversationProxyEnvelope(messages); + } + return buildApiPayload(buildConversationMessagesWithSystem(messages), inputs, parameterEnabled); + }, [buildConversationMessagesWithSystem, buildConversationProxyEnvelope, buildModeProxyEnvelope, customRequestBody, customRequestMode, inputs, messages, parameterEnabled]); useEffect(() => { setDebugPreview(formatJson(previewPayload)); }, [previewPayload]); const finalizeJob = useCallback((jobId: string) => { - void api.deleteTestChatJob(jobId).catch(() => { }); + void api.deleteProxyTestJob(jobId).catch(() => { }); }, []); useEffect(() => { @@ -737,7 +1190,7 @@ export default function ModelTester() { const pollTask = async () => { while (active) { try { - const status = await api.getTestChatJob(pendingJobId) as ChatJobResponse; + const status = await api.getProxyTestJob(pendingJobId) as ChatJobResponse; if (!active) return; if (status.status === 'pending') { @@ -807,24 +1260,27 @@ export default function ModelTester() { () => filteredModels.map((item) => ({ value: item, label: item })), [filteredModels], ); - const targetFormatOptions = useMemo>(() => ([ - { value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, - { value: 'responses', label: 'OpenAI Responses (/v1/responses)' }, - { value: 'claude', label: 'Claude (/v1/messages)' }, - ]), []); - const canSend = useMemo(() => { if (sending || pendingJobId || !inputs.model) return false; + if (inputs.mode !== 'conversation') { + if (inputs.mode === 'embeddings') return Boolean(embeddingInputText.trim()); + if (inputs.mode === 'search') return Boolean(searchQueryValue.trim()); + if (inputs.mode === 'images.generate') return Boolean(assetPrompt.trim()); + if (inputs.mode === 'images.edit') return Boolean(assetPrompt.trim()) && Boolean(imageSourceFile); + if (inputs.mode === 'videos.create') return Boolean(assetPrompt.trim()); + if (inputs.mode === 'videos.inspect') return Boolean(videoInspectId.trim()); + return false; + } const hasPrompt = input.trim().length > 0; if (!customRequestMode) return hasPrompt; return hasPrompt || customRequestBody.trim().length > 0; - }, [customRequestBody, customRequestMode, input, inputs.model, pendingJobId, sending]); + }, [assetPrompt, customRequestBody, customRequestMode, embeddingInputText, imageSourceFile, input, inputs.mode, inputs.model, pendingJobId, searchQueryValue, sending, videoInspectId]); const startChatJob = useCallback(async (payload: TestChatPayload) => { try { setError(''); setPendingPayload(payload); - const created = await api.startTestChatJob(payload) as { jobId: string }; + const created = await api.startProxyTestJob(payload) as { jobId: string }; setPendingJobId(created.jobId); setSending(true); pushDebug('info', `已创建任务 ${created.jobId}。`); @@ -860,7 +1316,7 @@ export default function ModelTester() { }; try { - const response = await api.testChatStream(payload, controller.signal); + const response = await api.proxyTestStream(payload, controller.signal); if (response.status === 401 || response.status === 403) { const hadToken = Boolean(getAuthToken(localStorage)); clearAuthSession(localStorage); @@ -966,6 +1422,124 @@ export default function ModelTester() { } }, [pushDebug]); + const startProxyStream = useCallback(async ( + envelope: ProxyTestEnvelope, + nextMessages: ChatMessage[], + ) => { + const controller = new AbortController(); + streamAbortRef.current = controller; + streamStopRequestedRef.current = false; + setSending(true); + setPendingJobId(null); + setPendingPayload(null); + setMessages(nextMessages); + setError(''); + setActiveDebugTab(DEBUG_TABS.RESPONSE); + pushDebug('info', `已开始代理流式请求:${envelope.path}`); + + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + const rawEvents: string[] = []; + const appendRawEvent = (raw: string) => { + rawEvents.push(raw); + if (rawEvents.length > 500) { + rawEvents.splice(0, rawEvents.length - 500); + } + setDebugResponse(rawEvents.join('\n')); + }; + + try { + const response = await api.proxyTestStream(envelope, controller.signal); + if (!response.ok) { + throw new Error(await parseStreamErrorText(response)); + } + if (!response.body) { + throw new Error('流式响应体为空'); + } + + const reader = response.body.getReader(); + let doneReceived = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split(/\r?\n\r?\n/); + buffer = chunks.pop() || ''; + + for (const chunk of chunks) { + const parsed = parseSseBlock(chunk); + if (!parsed.data) continue; + appendRawEvent(parsed.data); + + if (parsed.data === '[DONE]') { + doneReceived = true; + continue; + } + + let eventPayload: any; + try { + eventPayload = JSON.parse(parsed.data); + } catch { + continue; + } + + if (eventPayload?.error) { + throw new Error(extractErrorMessage(eventPayload)); + } + + const delta = parseAnyStreamDelta(eventPayload); + if (delta.reasoningDelta || delta.contentDelta) { + setMessages((prev) => applyAssistantDelta(prev, { + reasoningDelta: delta.reasoningDelta, + contentDelta: delta.contentDelta, + })); + } + if (delta.done) doneReceived = true; + } + } + + setMessages((prev) => { + const idx = findLastLoadingAssistantIndex(prev); + if (idx === -1) return prev; + return replaceMessageAt(prev, idx, { + ...finalizeIncompleteMessage(prev[idx]), + status: MESSAGE_STATUS.COMPLETE, + isThinkingComplete: true, + }); + }); + + setError(''); + pushDebug(doneReceived ? 'info' : 'warn', doneReceived + ? '代理流式传输已成功完成。' + : '代理流式传输未收到 [DONE] 信号,已在本地完成。'); + } catch (streamError: any) { + const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current; + const abortedUnexpectedly = controller.signal.aborted + || streamError?.name === 'AbortError' + || streamError?.message === 'This operation was aborted' + || streamError?.message === 'The user aborted a request.'; + + if (abortedByUser) { + setMessages((prev) => applyAssistantStopped(prev)); + setError('生成已停止。'); + } else if (abortedUnexpectedly) { + setMessages((prev) => applyAssistantError(prev, '流式连接中断,请重试。')); + setError('流式连接中断,请重试。'); + } else { + const message = streamError?.message || '流式请求失败'; + setMessages((prev) => applyAssistantError(prev, message)); + setError(message); + } + } finally { + if (streamAbortRef.current === controller) streamAbortRef.current = null; + streamStopRequestedRef.current = false; + setSending(false); + } + }, [pushDebug]); + const dispatchPayload = useCallback(async ( nextMessages: ChatMessage[], payload: TestChatPayload, @@ -989,28 +1563,80 @@ export default function ModelTester() { } }, [startChatJob, startStream]); + const dispatchProxyEnvelope = useCallback(async (envelope: ProxyTestEnvelope, nextMessages?: ChatMessage[]) => { + setError(''); + setDebugRequest(formatJson(envelope.rawMode ? { path: envelope.path, rawJsonText: envelope.rawJsonText } : envelope)); + setDebugResponse(''); + setActiveDebugTab(DEBUG_TABS.REQUEST); + setDebugTimestamp(new Date().toISOString()); + + if (envelope.stream && nextMessages) { + await startProxyStream(envelope, nextMessages); + return; + } + + setSending(true); + try { + const result = await api.proxyTest(envelope); + setDebugResponse(formatJson(result)); + setActiveDebugTab(DEBUG_TABS.RESPONSE); + setNonConversationResult(result); + + if (nextMessages) { + setMessages((prev) => applyAssistantSuccess(nextMessages, result)); + } + + setError(''); + pushDebug('info', `代理请求成功:${envelope.path}`); + } catch (requestError: any) { + const message = requestError?.message || '请求失败'; + if (nextMessages) { + setMessages((prev) => applyAssistantError(nextMessages, message)); + } + setError(message); + setDebugResponse(formatJson({ error: { message } })); + setActiveDebugTab(DEBUG_TABS.RESPONSE); + pushDebug('error', `代理请求失败:${message}`); + } finally { + setSending(false); + } + }, [pushDebug, startProxyStream]); + const buildPayloadWithMessages = useCallback((nextMessages: ChatMessage[]): { payload: TestChatPayload | null; syncedCustomBody?: string; } => { - if (!customRequestMode) { - return { payload: buildApiPayload(nextMessages, inputs, parameterEnabled) }; - } - - const syncedBody = syncMessagesToCustomRequestBody(customRequestBody, nextMessages, inputs); - const parsed = parseCustomRequestBody(syncedBody); + const effectiveMessages = buildConversationMessagesWithSystem(nextMessages); return { - payload: parsed - ? { ...parsed, targetFormat: parsed.targetFormat || inputs.targetFormat } - : null, - syncedCustomBody: syncedBody, + payload: customRequestMode + ? buildRawProxyRequestEnvelope( + 'POST', + buildConversationProxyEnvelope(effectiveMessages).path, + 'json', + customRequestBody, + { stream: inputs.stream, jobMode: !inputs.stream }, + ) + : buildApiPayload( + effectiveMessages, + { ...inputs, protocol: inputs.protocol as TestTargetFormat }, + parameterEnabled, + ), + syncedCustomBody: customRequestMode + ? customRequestBody + : syncMessagesToCustomRequestBody(customRequestBody, effectiveMessages, inputs), }; - }, [customRequestBody, customRequestMode, inputs, parameterEnabled]); + }, [buildConversationMessagesWithSystem, buildConversationProxyEnvelope, customRequestBody, customRequestMode, inputs, parameterEnabled]); const sendWithPrompt = useCallback(async (prompt: string, baseMessages: ChatMessage[]) => { const userMessage = createMessage('user', prompt); const loadingAssistant = createLoadingAssistantMessage(); const nextMessages = [...baseMessages, userMessage, loadingAssistant]; + const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode; + if (useProxyTransport) { + await dispatchProxyEnvelope(buildConversationProxyEnvelope(nextMessages), nextMessages); + return; + } + const { payload, syncedCustomBody } = buildPayloadWithMessages(nextMessages); if (!payload) { @@ -1020,11 +1646,25 @@ export default function ModelTester() { } await dispatchPayload(nextMessages, payload, { syncedCustomBody }); - }, [buildPayloadWithMessages, dispatchPayload, pushDebug]); + }, [buildConversationProxyEnvelope, buildPayloadWithMessages, customRequestMode, dispatchPayload, dispatchProxyEnvelope, inputs.protocol, pushDebug]); + + const sendModeRequest = useCallback(async () => { + const envelope = buildModeProxyEnvelope(); + if (!envelope) { + setError('请先补全当前模式所需的输入。'); + return; + } + await dispatchProxyEnvelope(envelope); + }, [buildModeProxyEnvelope, dispatchProxyEnvelope]); const send = useCallback(async () => { if (!canSend) return; + if (inputs.mode !== 'conversation') { + await sendModeRequest(); + return; + } + const trimmed = input.trim(); if (trimmed.length > 0) { setInput(''); @@ -1041,11 +1681,17 @@ export default function ModelTester() { } const nextMessages = [...messages, createLoadingAssistantMessage()]; - await dispatchPayload(nextMessages, { - ...payload, - targetFormat: payload.targetFormat || inputs.targetFormat, - }); - }, [canSend, customRequestBody, customRequestMode, dispatchPayload, input, inputs.targetFormat, messages, pushDebug, sendWithPrompt]); + await dispatchPayload( + nextMessages, + buildRawProxyRequestEnvelope( + 'POST', + buildConversationProxyEnvelope(nextMessages).path, + 'json', + customRequestBody, + { stream: inputs.stream, jobMode: !inputs.stream }, + ), + ); + }, [canSend, customRequestBody, customRequestMode, dispatchPayload, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt]); const retryPending = useCallback(async () => { if (sending || pendingJobId || !pendingPayload) return; @@ -1083,7 +1729,7 @@ export default function ModelTester() { const jobId = pendingJobId; setPendingJobId(null); try { - await api.deleteTestChatJob(jobId); + await api.deleteProxyTestJob(jobId); } catch { // no-op } @@ -1098,7 +1744,7 @@ export default function ModelTester() { const clearChat = useCallback(() => { if (pendingJobId) { - void api.deleteTestChatJob(pendingJobId).catch(() => { }); + void api.deleteProxyTestJob(pendingJobId).catch(() => { }); } if (streamAbortRef.current) { streamStopRequestedRef.current = true; @@ -1123,6 +1769,17 @@ export default function ModelTester() { setDebugPreview(''); setDebugTimeline([]); setDebugTimestamp(''); + setNonConversationResult(null); + setSearchQueryValue(''); + setSearchAllowedDomains(''); + setSearchBlockedDomains(''); + setSearchMaxResults(10); + setEmbeddingInputText(''); + setAssetPrompt(''); + setVideoInspectId(''); + setVideoInspectAction('GET'); + setImageSourceFile(null); + setImageMaskFile(null); localStorage.removeItem(MODEL_TESTER_STORAGE_KEY); pushDebug('info', '对话已清除。'); }, [pendingJobId, pushDebug]); @@ -1357,13 +2014,17 @@ export default function ModelTester() {
模式
- {(customRequestMode ? '自定义请求' : (inputs.stream ? '流式' : '任务模式'))} + {inputs.mode === 'conversation' + ? (customRequestMode ? '自定义请求' : (inputs.stream ? '流式' : '任务模式')) + : inputs.mode} {' / '} - {inputs.targetFormat === 'claude' + {inputs.protocol === 'claude' ? 'Claude' - : inputs.targetFormat === 'responses' + : inputs.protocol === 'responses' ? 'OpenAI Responses' - : 'OpenAI'} + : inputs.protocol === 'gemini' + ? 'Gemini' + : 'OpenAI'}
@@ -1380,6 +2041,21 @@ export default function ModelTester() {

设置

+
+
+ 测试模式 +
+ { + if (!next) return; + updateInput('mode', next as PlaygroundMode); + }} + options={CONVERSATION_MODE_OPTIONS} + placeholder="请选择测试模式" + /> +
+
模型
@@ -1439,22 +2115,42 @@ export default function ModelTester() {
- 下游协议 + 协议 / 输出格式
{ if (!next) return; - updateInput('targetFormat', next as TestTargetFormat); + updateProtocol(next as PlaygroundProtocol); }} - options={targetFormatOptions} - placeholder="请选择下游协议" + options={PROTOCOL_OPTIONS} + placeholder="请选择协议" />
- 用于模拟不同客户端接入格式(OpenAI / Claude)。 + 对话模式下可模拟 OpenAI / Responses / Claude / Gemini Native。
+ {inputs.mode === 'conversation' && ( +
+
+ System Prompt +
+