From 6cca74b039d482d8e72e7f00a83eabd69279194c Mon Sep 17 00:00:00 2001 From: cita-777 Date: Thu, 9 Apr 2026 19:43:47 +0800 Subject: [PATCH 01/13] Recognize more LobeHub-backed model brands so marketplace filters stop collapsing into other Metapi was leaving many models under the uncategorized bucket even though the current LobeHub icon set already contains matching provider or vendor brands. This change moves brand detection into one shared registry, expands the rules with the user-reported gaps that have real upstream icon coverage, and makes server-side brand blocking reuse the same classification result as the web UI. Constraint: Existing workspace has unrelated in-progress edits, so this commit stages only the brand-classification slice Constraint: Brand matching must stay consistent between marketplace display and server-side blocked-brand filtering Rejected: Add more ad hoc rules separately in web and server files | would keep the drift bug alive Rejected: Blindly import every LobeHub slug as a model brand | would pollute filters with tool and app brands Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep new brand additions in src/server/shared/modelBrand.ts and let web/server consume that single source of truth Tested: npm test -- src/web/components/BrandIcon.test.ts src/server/services/brandMatcher.test.ts src/web/pages/Models.marketplace-text.test.tsx src/web/components/BrandIcon.architecture.test.ts src/web/components/BrandIcon.render.test.tsx Tested: npm run typecheck Tested: npm run repo:drift-check Not-tested: Live browser verification against the user's current marketplace dataset after deployment --- src/server/services/brandMatcher.test.ts | 41 + src/server/services/brandMatcher.ts | 275 +----- src/server/shared/modelBrand.ts | 806 ++++++++++++++++++ src/web/components/BrandIcon.test.ts | 41 + src/web/components/brandRegistry.ts | 337 +------- .../pages/Models.marketplace-text.test.tsx | 208 +++++ 6 files changed, 1121 insertions(+), 587 deletions(-) create mode 100644 src/server/services/brandMatcher.test.ts create mode 100644 src/server/shared/modelBrand.ts diff --git a/src/server/services/brandMatcher.test.ts b/src/server/services/brandMatcher.test.ts new file mode 100644 index 0000000..4fe7e67 --- /dev/null +++ b/src/server/services/brandMatcher.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { getAllBrandNames, getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js'; + +describe('brandMatcher', () => { + it('exposes newly supported provider and platform brands', () => { + const brands = getAllBrandNames(); + + expect(brands).toContain('OpenRouter'); + expect(brands).toContain('Groq'); + expect(brands).toContain('DeepInfra'); + expect(brands).toContain('Fireworks'); + expect(brands).toContain('Together AI'); + expect(brands).toContain('AI21 Labs'); + expect(brands).toContain('百川智能'); + expect(brands).toContain('Amazon Nova'); + expect(brands).toContain('百炼'); + expect(brands).toContain('Arcee'); + expect(brands).toContain('Xiaomi MiMo'); + expect(brands).toContain('LongCat'); + }); + + it('deduplicates blocked brands and ignores unknown entries', () => { + expect(getBlockedBrandRules(['OpenRouter', 'OpenRouter', 'Unknown Brand'])).toEqual(['OpenRouter']); + }); + + it('uses the shared frontend detection result for global brand blocking', () => { + const providerRules = getBlockedBrandRules(['OpenRouter', 'Groq', '百炼', 'LongCat']); + expect(isModelBlockedByBrand('openrouter/openrouter-auto', providerRules)).toBe(true); + expect(isModelBlockedByBrand('groq/compound-beta', providerRules)).toBe(true); + expect(isModelBlockedByBrand('dashscope/wanx2.1-t2i-turbo', providerRules)).toBe(true); + expect(isModelBlockedByBrand('LongCat-Flash-Lite', providerRules)).toBe(true); + + const vendorRules = getBlockedBrandRules(['Anthropic', 'Meta', 'Google', 'OpenAI', 'Arcee', 'Xiaomi MiMo']); + expect(isModelBlockedByBrand('openrouter/anthropic/claude-3-7-sonnet', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('deepinfra/meta-llama/llama-3.3-70b-instruct', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('vertexai/google/gemini-2.5-pro', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('azureai/gpt-4o', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('arcee-ai/trinity-mini', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('xiaomi/mimo-v2-pro', vendorRules)).toBe(true); + }); +}); diff --git a/src/server/services/brandMatcher.ts b/src/server/services/brandMatcher.ts index f4a93ac..a5f67c4 100644 --- a/src/server/services/brandMatcher.ts +++ b/src/server/services/brandMatcher.ts @@ -1,273 +1,28 @@ -/** - * Server-side brand matching for global brand filtering. - * - * Context-building logic is ported from the frontend BrandIcon.tsx - * (collectBrandCandidates + buildMatchContext) to ensure identical - * matching behavior between route display and rebuild filtering. - */ +import { getAllBrandNames as getSharedBrandNames, getBrand } from '../shared/modelBrand.js'; -type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary'; -type KeywordRule = { keyword: string; mode: MatchMode }; +export type BlockedBrandRule = string; -const BRAND_RULES: Record = { - 'OpenAI': [ - { keyword: 'gpt', mode: 'startsWith' }, - { keyword: 'chatgpt', mode: 'startsWith' }, - { keyword: 'dall-e', mode: 'startsWith' }, - { keyword: 'whisper', mode: 'startsWith' }, - { keyword: 'text-embedding', mode: 'startsWith' }, - { keyword: 'text-moderation', mode: 'startsWith' }, - { keyword: 'davinci', mode: 'startsWith' }, - { keyword: 'babbage', mode: 'startsWith' }, - { keyword: 'codex-mini', mode: 'startsWith' }, - { keyword: 'o1', mode: 'startsWith' }, - { keyword: 'o3', mode: 'startsWith' }, - { keyword: 'o4', mode: 'startsWith' }, - { keyword: 'tts', mode: 'startsWith' }, - ], - 'Anthropic': [ - { keyword: 'claude', mode: 'includes' }, - ], - 'Google': [ - { keyword: 'gemini', mode: 'includes' }, - { keyword: 'gemma', mode: 'includes' }, - { keyword: 'google/', mode: 'includes' }, - { keyword: 'palm', mode: 'includes' }, - { keyword: 'paligemma', mode: 'includes' }, - { keyword: 'shieldgemma', mode: 'includes' }, - { keyword: 'recurrentgemma', mode: 'includes' }, - { keyword: 'deplot', mode: 'includes' }, - { keyword: 'codegemma', mode: 'includes' }, - { keyword: 'imagen', mode: 'includes' }, - { keyword: 'learnlm', mode: 'includes' }, - { keyword: 'aqa', mode: 'includes' }, - { keyword: 'veo', mode: 'startsWith' }, - ], - 'DeepSeek': [ - { keyword: 'deepseek', mode: 'includes' }, - { keyword: 'ds-chat', mode: 'segment' }, - ], - '通义千问': [ - { keyword: 'qwen', mode: 'includes' }, - { keyword: 'qwq', mode: 'includes' }, - { keyword: 'tongyi', mode: 'includes' }, - ], - '智谱 AI': [ - { keyword: 'glm', mode: 'includes' }, - { keyword: 'chatglm', mode: 'includes' }, - { keyword: 'codegeex', mode: 'includes' }, - { keyword: 'cogview', mode: 'includes' }, - { keyword: 'cogvideo', mode: 'includes' }, - ], - 'Meta': [ - { keyword: 'llama', mode: 'includes' }, - { keyword: 'code-llama', mode: 'includes' }, - { keyword: 'codellama', mode: 'includes' }, - ], - 'Mistral': [ - { keyword: 'mistral', mode: 'includes' }, - { keyword: 'mixtral', mode: 'includes' }, - { keyword: 'codestral', mode: 'includes' }, - { keyword: 'pixtral', mode: 'includes' }, - { keyword: 'ministral', mode: 'includes' }, - { keyword: 'voxtral', mode: 'includes' }, - { keyword: 'magistral', mode: 'includes' }, - ], - 'Moonshot': [ - { keyword: 'moonshot', mode: 'includes' }, - { keyword: 'kimi', mode: 'includes' }, - ], - '零一万物': [ - { keyword: 'yi-', mode: 'startsWith' }, - { keyword: 'yi', mode: 'boundary' }, - ], - '文心一言': [ - { keyword: 'ernie', mode: 'includes' }, - { keyword: 'eb-', mode: 'includes' }, - ], - '讯飞星火': [ - { keyword: 'spark', mode: 'includes' }, - { keyword: 'generalv', mode: 'includes' }, - ], - '腾讯混元': [ - { keyword: 'hunyuan', mode: 'includes' }, - { keyword: 'tencent-hunyuan', mode: 'includes' }, - ], - '豆包': [ - { keyword: 'doubao', mode: 'includes' }, - ], - 'MiniMax': [ - { keyword: 'minimax', mode: 'includes' }, - { keyword: 'abab', mode: 'includes' }, - { keyword: 'mini2.1', mode: 'segment' }, - ], - 'Cohere': [ - { keyword: 'command', mode: 'includes' }, - { keyword: 'c4ai-', mode: 'includes' }, - { keyword: 'embed-', mode: 'startsWith' }, - ], - 'Microsoft': [ - { keyword: 'microsoft/', mode: 'includes' }, - { keyword: 'phi-', mode: 'includes' }, - { keyword: 'kosmos', mode: 'includes' }, - { keyword: 'phi4', mode: 'segment' }, - ], - 'xAI': [ - { keyword: 'grok', mode: 'includes' }, - ], - '阶跃星辰': [ - { keyword: 'stepfun', mode: 'includes' }, - { keyword: 'step-', mode: 'startsWith' }, - { keyword: 'step3', mode: 'startsWith' }, - ], - 'Stability': [ - { keyword: 'flux', mode: 'includes' }, - { keyword: 'stablediffusion', mode: 'includes' }, - { keyword: 'stable-diffusion', mode: 'includes' }, - { keyword: 'sdxl', mode: 'includes' }, - { keyword: 'sd3', mode: 'startsWith' }, - ], - 'NVIDIA': [ - { keyword: 'nvidia/', mode: 'includes' }, - { keyword: 'nvclip', mode: 'includes' }, - { keyword: 'nemotron', mode: 'includes' }, - { keyword: 'nemoretriever', mode: 'includes' }, - { keyword: 'neva', mode: 'includes' }, - { keyword: 'riva-translate', mode: 'includes' }, - { keyword: 'cosmos', mode: 'includes' }, - { keyword: 'nv-', mode: 'startsWith' }, - ], - 'IBM': [ - { keyword: 'ibm/', mode: 'includes' }, - { keyword: 'granite', mode: 'includes' }, - ], - 'BAAI': [ - { keyword: 'baai/', mode: 'includes' }, - { keyword: 'bge-', mode: 'includes' }, - ], - 'ByteDance': [ - { keyword: 'bytedance', mode: 'includes' }, - { keyword: 'seed-oss', mode: 'includes' }, - { keyword: 'kolors', mode: 'includes' }, - { keyword: 'kwai', mode: 'includes' }, - { keyword: 'kwaipilot', mode: 'includes' }, - { keyword: 'wan-', mode: 'startsWith' }, - { keyword: 'kat-', mode: 'startsWith' }, - ], - 'InternLM': [ - { keyword: 'internlm', mode: 'includes' }, - ], - 'Midjourney': [ - { keyword: 'midjourney', mode: 'includes' }, - { keyword: 'mj_', mode: 'startsWith' }, - ], - 'DeepL': [ - { keyword: 'deepl/', mode: 'includes' }, - { keyword: 'deepl-', mode: 'startsWith' }, - ], - 'Jina AI': [ - { keyword: 'jina', mode: 'includes' }, - ], -}; - -// ── Context building (ported from frontend BrandIcon.tsx) ── - -function stripCommonWrappers(value: string): string { - return value - .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') - .replace(/^re:\s*/g, '') - .replace(/^\^+/, '') - .replace(/\$+$/, '') - .trim(); -} - -/** - * Recursively split model name on `/`, `:`, `,` to build all candidate strings. - * Mirrors the frontend collectBrandCandidates exactly. - */ -function collectCandidates(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); - }; - - push(modelName); - - for (let i = 0; i < queue.length; i++) { - const candidate = queue[i]!; - 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; -} - -type MatchContext = { - raw: string; // first candidate (normalized full name) - candidates: string[]; // all candidates from recursive splitting - segments: string[]; // candidates split on /,:,\s (NOT on - or _) -}; - -function buildMatchContext(modelName: string): MatchContext { - const candidates = collectCandidates(modelName); - const raw = candidates[0] || modelName.trim().toLowerCase(); - // Segments split on /,:,whitespace only — NOT on - or _ (matches frontend) - const segments = Array.from(new Set( - candidates - .flatMap((c) => c.split(/[/:,\s]+/g)) - .map((s) => s.trim()) - .filter(Boolean), - )); - return { raw, candidates, segments }; -} - -// ── Matching functions (mirror frontend includesAny / startsWithAny / hasExactSegment / matchesBoundary) ── - -function matchesRule(ctx: MatchContext, rule: KeywordRule): boolean { - switch (rule.mode) { - case 'includes': - return ctx.raw.includes(rule.keyword) - || ctx.candidates.some((c) => c.includes(rule.keyword)); - case 'startsWith': - return ctx.raw.startsWith(rule.keyword) - || ctx.segments.some((s) => s.startsWith(rule.keyword)) - || ctx.candidates.some((c) => c.startsWith(rule.keyword)); - case 'segment': - return ctx.segments.includes(rule.keyword); - case 'boundary': { - const pattern = new RegExp(`(^|[/:_\\-\\s])${rule.keyword}(?=$|[/:_\\-\\s])`); - return pattern.test(ctx.raw) || ctx.candidates.some((c) => pattern.test(c)); - } - default: - return false; - } -} - -// ── Public API ── +const KNOWN_BRANDS = new Set(getSharedBrandNames()); +const KNOWN_BRAND_LIST = Array.from(KNOWN_BRANDS); export function getAllBrandNames(): string[] { - return Object.keys(BRAND_RULES); + return KNOWN_BRAND_LIST; } -export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] { - const rules: KeywordRule[] = []; +export function getBlockedBrandRules(blockedBrands: string[]): BlockedBrandRule[] { + const seen = new Set(); + const rules: string[] = []; for (const brand of blockedBrands) { - const brandRules = BRAND_RULES[brand]; - if (brandRules) rules.push(...brandRules); + const normalized = String(brand || '').trim(); + if (!normalized || seen.has(normalized) || !KNOWN_BRANDS.has(normalized)) continue; + seen.add(normalized); + rules.push(normalized); } return rules; } -export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean { +export function isModelBlockedByBrand(modelName: string, rules: BlockedBrandRule[]): boolean { if (!modelName || rules.length === 0) return false; - const ctx = buildMatchContext(modelName); - if (!ctx.raw) return false; - return rules.some((rule) => matchesRule(ctx, rule)); + const brand = getBrand(modelName); + return !!brand && rules.includes(brand.name); } diff --git a/src/server/shared/modelBrand.ts b/src/server/shared/modelBrand.ts new file mode 100644 index 0000000..de93d1a --- /dev/null +++ b/src/server/shared/modelBrand.ts @@ -0,0 +1,806 @@ +export interface BrandMatchContext { + raw: string; + cleaned: string; + segments: string[]; + candidates: string[]; +} + +export interface BrandInfo { + name: string; + icon: string; + color: string; +} + +export type BrandMatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary'; + +type BrandRule = { + keyword: string; + mode: BrandMatchMode; +}; + +type BrandDefinition = BrandInfo & { + rules: BrandRule[]; +}; + +const BRAND_DEFINITIONS: BrandDefinition[] = [ + { + name: 'OpenAI', + icon: 'openai', + color: 'linear-gradient(135deg, #10a37f, #1a7f5a)', + rules: [ + { keyword: 'gpt', mode: 'startsWith' }, + { keyword: 'chatgpt', mode: 'startsWith' }, + { keyword: 'dall-e', mode: 'startsWith' }, + { keyword: 'whisper', mode: 'startsWith' }, + { keyword: 'text-embedding', mode: 'startsWith' }, + { keyword: 'text-moderation', mode: 'startsWith' }, + { keyword: 'davinci', mode: 'startsWith' }, + { keyword: 'babbage', mode: 'startsWith' }, + { keyword: 'codex-mini', mode: 'startsWith' }, + { keyword: 'o1', mode: 'startsWith' }, + { keyword: 'o3', mode: 'startsWith' }, + { keyword: 'o4', mode: 'startsWith' }, + { keyword: 'tts', mode: 'startsWith' }, + ], + }, + { + name: 'Anthropic', + icon: 'claude-color', + color: 'linear-gradient(135deg, #d4a574, #c4956a)', + rules: [ + { keyword: 'claude', mode: 'includes' }, + ], + }, + { + name: 'Google', + icon: 'gemini-color', + color: 'linear-gradient(135deg, #4285f4, #34a853)', + rules: [ + { keyword: 'gemini', mode: 'includes' }, + { keyword: 'gemma', mode: 'includes' }, + { keyword: 'google/', mode: 'includes' }, + { keyword: 'palm', mode: 'includes' }, + { keyword: 'paligemma', mode: 'includes' }, + { keyword: 'shieldgemma', mode: 'includes' }, + { keyword: 'recurrentgemma', mode: 'includes' }, + { keyword: 'deplot', mode: 'includes' }, + { keyword: 'codegemma', mode: 'includes' }, + { keyword: 'imagen', mode: 'includes' }, + { keyword: 'learnlm', mode: 'includes' }, + { keyword: 'aqa', mode: 'includes' }, + { keyword: 'veo', mode: 'startsWith' }, + { keyword: 'google/', mode: 'startsWith' }, + ], + }, + { + name: 'DeepSeek', + icon: 'deepseek-color', + color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)', + rules: [ + { keyword: 'deepseek', mode: 'includes' }, + { keyword: 'ds-chat', mode: 'segment' }, + ], + }, + { + name: '通义千问', + icon: 'qwen-color', + color: 'linear-gradient(135deg, #615cf7, #9b8afb)', + rules: [ + { keyword: 'qwen', mode: 'includes' }, + { keyword: 'qwq', mode: 'includes' }, + { keyword: 'tongyi', mode: 'includes' }, + ], + }, + { + name: '智谱 AI', + icon: 'zhipu-color', + color: 'linear-gradient(135deg, #3b6cf5, #6366f1)', + rules: [ + { keyword: 'glm', mode: 'includes' }, + { keyword: 'chatglm', mode: 'includes' }, + { keyword: 'codegeex', mode: 'includes' }, + { keyword: 'cogview', mode: 'includes' }, + { keyword: 'cogvideo', mode: 'includes' }, + ], + }, + { + name: 'Meta', + icon: 'meta-color', + color: 'linear-gradient(135deg, #0668E1, #1877f2)', + rules: [ + { keyword: 'llama', mode: 'includes' }, + { keyword: 'code-llama', mode: 'includes' }, + { keyword: 'codellama', mode: 'includes' }, + ], + }, + { + name: 'Mistral', + icon: 'mistral-color', + color: 'linear-gradient(135deg, #f7d046, #f2a900)', + rules: [ + { keyword: 'mistral', mode: 'includes' }, + { keyword: 'mixtral', mode: 'includes' }, + { keyword: 'codestral', mode: 'includes' }, + { keyword: 'pixtral', mode: 'includes' }, + { keyword: 'ministral', mode: 'includes' }, + { keyword: 'voxtral', mode: 'includes' }, + { keyword: 'magistral', mode: 'includes' }, + ], + }, + { + name: 'Moonshot', + icon: 'moonshot', + color: 'linear-gradient(135deg, #000000, #333333)', + rules: [ + { keyword: 'moonshot', mode: 'includes' }, + { keyword: 'kimi', mode: 'includes' }, + ], + }, + { + name: '零一万物', + icon: 'yi-color', + color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)', + rules: [ + { keyword: 'yi-', mode: 'startsWith' }, + { keyword: 'yi', mode: 'boundary' }, + ], + }, + { + name: '文心一言', + icon: 'wenxin-color', + color: 'linear-gradient(135deg, #2932e1, #4468f2)', + rules: [ + { keyword: 'ernie', mode: 'includes' }, + { keyword: 'eb-', mode: 'includes' }, + ], + }, + { + name: '讯飞星火', + icon: 'spark-color', + color: 'linear-gradient(135deg, #0070f3, #00d4ff)', + rules: [ + { keyword: 'spark', mode: 'includes' }, + { keyword: 'generalv', mode: 'includes' }, + ], + }, + { + name: '腾讯混元', + icon: 'hunyuan-color', + color: 'linear-gradient(135deg, #00b7ff, #0052d9)', + rules: [ + { keyword: 'hunyuan', mode: 'includes' }, + { keyword: 'tencent-hunyuan', mode: 'includes' }, + ], + }, + { + name: '豆包', + icon: 'doubao-color', + color: 'linear-gradient(135deg, #3b5bdb, #7048e8)', + rules: [ + { keyword: 'doubao', mode: 'includes' }, + ], + }, + { + name: 'MiniMax', + icon: 'minimax-color', + color: 'linear-gradient(135deg, #6366f1, #818cf8)', + rules: [ + { keyword: 'minimax', mode: 'includes' }, + { keyword: 'abab', mode: 'includes' }, + { keyword: 'mini2.1', mode: 'segment' }, + ], + }, + { + name: 'Cohere', + icon: 'cohere-color', + color: 'linear-gradient(135deg, #39594d, #5ba77f)', + rules: [ + { keyword: 'command', mode: 'includes' }, + { keyword: 'c4ai-', mode: 'includes' }, + { keyword: 'aya', mode: 'includes' }, + { keyword: 'embed-', mode: 'startsWith' }, + ], + }, + { + name: 'Microsoft', + icon: 'microsoft-color', + color: 'linear-gradient(135deg, #00bcf2, #0078d4)', + rules: [ + { keyword: 'microsoft/', mode: 'includes' }, + { keyword: 'phi-', mode: 'includes' }, + { keyword: 'kosmos', mode: 'includes' }, + { keyword: 'phi4', mode: 'segment' }, + ], + }, + { + name: 'xAI', + icon: 'xai', + color: 'linear-gradient(135deg, #111, #444)', + rules: [ + { keyword: 'grok', mode: 'includes' }, + ], + }, + { + name: '阶跃星辰', + icon: 'stepfun-color', + color: 'linear-gradient(135deg, #0066ff, #3399ff)', + rules: [ + { keyword: 'stepfun', mode: 'includes' }, + { keyword: 'step-', mode: 'startsWith' }, + { keyword: 'step3', mode: 'startsWith' }, + ], + }, + { + name: '百川智能', + icon: 'baichuan-color', + color: 'linear-gradient(135deg, #0f766e, #14b8a6)', + rules: [ + { keyword: 'baichuan', mode: 'includes' }, + ], + }, + { + name: 'AI21 Labs', + icon: 'ai21-brand-color', + color: 'linear-gradient(135deg, #7c3aed, #a855f7)', + rules: [ + { keyword: 'ai21', mode: 'includes' }, + { keyword: 'jamba', mode: 'startsWith' }, + { keyword: 'jamba', mode: 'includes' }, + ], + }, + { + name: 'AI2', + icon: 'ai2-color', + color: 'linear-gradient(135deg, #0f766e, #14b8a6)', + rules: [ + { keyword: 'allenai', mode: 'includes' }, + { keyword: 'olmo', mode: 'includes' }, + ], + }, + { + name: 'Amazon Nova', + icon: 'nova', + color: 'linear-gradient(135deg, #f59e0b, #f97316)', + rules: [ + { keyword: 'amazon/nova', mode: 'startsWith' }, + { keyword: 'amazon.nova', mode: 'includes' }, + { keyword: 'us.amazon.nova', mode: 'includes' }, + { keyword: 'nova-', mode: 'startsWith' }, + { keyword: 'nova-lite', mode: 'startsWith' }, + { keyword: 'nova-pro', mode: 'startsWith' }, + { keyword: 'nova-micro', mode: 'startsWith' }, + { keyword: 'nova-canvas', mode: 'startsWith' }, + { keyword: 'nova-reel', mode: 'startsWith' }, + ], + }, + { + name: 'Stability', + icon: 'stability-color', + color: 'linear-gradient(135deg, #8b5cf6, #a855f7)', + rules: [ + { keyword: 'flux', mode: 'includes' }, + { keyword: 'stablediffusion', mode: 'includes' }, + { keyword: 'stable-diffusion', mode: 'includes' }, + { keyword: 'sdxl', mode: 'includes' }, + { keyword: 'sd3', mode: 'startsWith' }, + ], + }, + { + name: 'NVIDIA', + icon: 'nvidia-color', + color: 'linear-gradient(135deg, #76b900, #4a8c0b)', + rules: [ + { keyword: 'nvidia/', mode: 'includes' }, + { keyword: 'nvclip', mode: 'includes' }, + { keyword: 'nemotron', mode: 'includes' }, + { keyword: 'nemoretriever', mode: 'includes' }, + { keyword: 'neva', mode: 'includes' }, + { keyword: 'riva-translate', mode: 'includes' }, + { keyword: 'cosmos', mode: 'includes' }, + { keyword: 'nv-', mode: 'startsWith' }, + ], + }, + { + name: 'IBM', + icon: 'ibm', + color: 'linear-gradient(135deg, #0f62fe, #4589ff)', + rules: [ + { keyword: 'ibm/', mode: 'includes' }, + { keyword: 'granite', mode: 'includes' }, + ], + }, + { + name: 'BAAI', + icon: 'baai', + color: 'linear-gradient(135deg, #111827, #374151)', + rules: [ + { keyword: 'baai/', mode: 'includes' }, + { keyword: 'bge-', mode: 'includes' }, + ], + }, + { + name: 'ByteDance', + icon: 'bytedance-color', + color: 'linear-gradient(135deg, #325ab4, #0f66ff)', + rules: [ + { keyword: 'bytedance', mode: 'includes' }, + { keyword: 'seed-oss', mode: 'includes' }, + { keyword: 'kolors', mode: 'includes' }, + { keyword: 'kwai', mode: 'includes' }, + { keyword: 'kwaipilot', mode: 'includes' }, + { keyword: 'wan-', mode: 'startsWith' }, + { keyword: 'kat-', mode: 'startsWith' }, + ], + }, + { + name: 'InternLM', + icon: 'internlm-color', + color: 'linear-gradient(135deg, #1b3882, #4063c5)', + rules: [ + { keyword: 'internlm', mode: 'includes' }, + ], + }, + { + name: 'Midjourney', + icon: 'midjourney', + color: 'linear-gradient(135deg, #4c6ef5, #748ffc)', + rules: [ + { keyword: 'midjourney', mode: 'includes' }, + { keyword: 'mj_', mode: 'startsWith' }, + ], + }, + { + name: 'DeepL', + icon: 'deepl-color', + color: 'linear-gradient(135deg, #0f2b46, #21476f)', + rules: [ + { keyword: 'deepl-', mode: 'startsWith' }, + { keyword: 'deepl/', mode: 'includes' }, + ], + }, + { + name: 'Jina AI', + icon: 'jina', + color: 'linear-gradient(135deg, #111827, #4b5563)', + rules: [ + { keyword: 'jina', mode: 'includes' }, + ], + }, + { + name: 'Relace', + icon: 'relace', + color: 'linear-gradient(135deg, #7c3aed, #6366f1)', + rules: [ + { keyword: 'relace', mode: 'includes' }, + ], + }, + { + name: 'Arcee', + icon: 'arcee-color', + color: 'linear-gradient(135deg, #2563eb, #60a5fa)', + rules: [ + { keyword: 'arcee-ai', mode: 'includes' }, + { keyword: 'arcee', mode: 'includes' }, + ], + }, + { + name: 'AionLabs', + icon: 'aionlabs-color', + color: 'linear-gradient(135deg, #0f766e, #14b8a6)', + rules: [ + { keyword: 'aion-labs', mode: 'includes' }, + { keyword: 'aionlabs', mode: 'includes' }, + ], + }, + { + name: 'DeepCogito', + icon: 'deepcogito-color', + color: 'linear-gradient(135deg, #2563eb, #7c3aed)', + rules: [ + { keyword: 'deepcogito', mode: 'includes' }, + ], + }, + { + name: 'Essential AI', + icon: 'essentialai-color', + color: 'linear-gradient(135deg, #0f172a, #334155)', + rules: [ + { keyword: 'essentialai', mode: 'includes' }, + ], + }, + { + name: 'Inception', + icon: 'inception', + color: 'linear-gradient(135deg, #7c3aed, #ec4899)', + rules: [ + { keyword: 'inception', mode: 'includes' }, + ], + }, + { + name: 'Inflection', + icon: 'inflection', + color: 'linear-gradient(135deg, #1d4ed8, #2563eb)', + rules: [ + { keyword: 'inflection', mode: 'includes' }, + ], + }, + { + name: 'Liquid AI', + icon: 'liquid', + color: 'linear-gradient(135deg, #0f172a, #475569)', + rules: [ + { keyword: 'liquid', mode: 'includes' }, + { keyword: 'lfm-', mode: 'startsWith' }, + ], + }, + { + name: 'LongCat', + icon: 'longcat-color', + color: 'linear-gradient(135deg, #f97316, #fb7185)', + rules: [ + { keyword: 'longcat', mode: 'includes' }, + ], + }, + { + name: 'Morph', + icon: 'morph-color', + color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)', + rules: [ + { keyword: 'morph/', mode: 'includes' }, + { keyword: 'morph-', mode: 'startsWith' }, + ], + }, + { + name: 'Nous Research', + icon: 'nousresearch', + color: 'linear-gradient(135deg, #111827, #4b5563)', + rules: [ + { keyword: 'nousresearch', mode: 'includes' }, + ], + }, + { + name: 'Upstage', + icon: 'upstage-color', + color: 'linear-gradient(135deg, #2563eb, #14b8a6)', + rules: [ + { keyword: 'upstage', mode: 'includes' }, + ], + }, + { + name: 'Xiaomi MiMo', + icon: 'xiaomimimo', + color: 'linear-gradient(135deg, #f97316, #fb923c)', + rules: [ + { keyword: 'xiaomi/mimo', mode: 'includes' }, + { keyword: 'xiaomimimo', mode: 'includes' }, + { keyword: 'mimo-v', mode: 'startsWith' }, + ], + }, + { + name: 'Z.ai', + icon: 'zai', + color: 'linear-gradient(135deg, #0f172a, #2563eb)', + rules: [ + { keyword: '2zai', mode: 'startsWith' }, + { keyword: 'z-ai', mode: 'startsWith' }, + ], + }, + { + name: 'SenseNova', + icon: 'sensenova-brand-color', + color: 'linear-gradient(135deg, #f59e0b, #f97316)', + rules: [ + { keyword: 'sensenova', mode: 'includes' }, + ], + }, + { + name: 'Perplexity', + icon: 'perplexity-color', + color: 'linear-gradient(135deg, #0f766e, #14b8a6)', + rules: [ + { keyword: 'perplexity', mode: 'includes' }, + { keyword: 'pplx-', mode: 'startsWith' }, + ], + }, + { + name: 'OpenRouter', + icon: 'openrouter', + color: 'linear-gradient(135deg, #7c3aed, #2563eb)', + rules: [ + { keyword: 'openrouter', mode: 'includes' }, + { keyword: 'openrouter-', mode: 'startsWith' }, + ], + }, + { + name: 'Groq', + icon: 'groq', + color: 'linear-gradient(135deg, #111827, #374151)', + rules: [ + { keyword: 'groq', mode: 'includes' }, + ], + }, + { + name: 'Fireworks', + icon: 'fireworks-color', + color: 'linear-gradient(135deg, #fb7185, #f97316)', + rules: [ + { keyword: 'fireworks-ai', mode: 'includes' }, + { keyword: 'fireworks', mode: 'includes' }, + ], + }, + { + name: 'DeepInfra', + icon: 'deepinfra-color', + color: 'linear-gradient(135deg, #4f46e5, #7c3aed)', + rules: [ + { keyword: 'deepinfra', mode: 'includes' }, + ], + }, + { + name: 'Together AI', + icon: 'together-brand-color', + color: 'linear-gradient(135deg, #7c3aed, #ec4899)', + rules: [ + { keyword: 'together.ai', mode: 'includes' }, + { keyword: 'together', mode: 'includes' }, + ], + }, + { + name: 'Replicate', + icon: 'replicate-brand', + color: 'linear-gradient(135deg, #111827, #6366f1)', + rules: [ + { keyword: 'replicate', mode: 'includes' }, + ], + }, + { + name: 'SambaNova', + icon: 'sambanova-color', + color: 'linear-gradient(135deg, #2563eb, #06b6d4)', + rules: [ + { keyword: 'sambanova', mode: 'includes' }, + ], + }, + { + name: 'Cerebras', + icon: 'cerebras-brand-color', + color: 'linear-gradient(135deg, #0f766e, #65a30d)', + rules: [ + { keyword: 'cerebras', mode: 'includes' }, + ], + }, + { + name: 'Ollama', + icon: 'ollama', + color: 'linear-gradient(135deg, #1f2937, #4b5563)', + rules: [ + { keyword: 'ollama', mode: 'includes' }, + ], + }, + { + name: 'ModelScope', + icon: 'modelscope-color', + color: 'linear-gradient(135deg, #2563eb, #60a5fa)', + rules: [ + { keyword: 'modelscope', mode: 'includes' }, + ], + }, + { + name: 'SiliconCloud', + icon: 'siliconcloud-color', + color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)', + rules: [ + { keyword: 'siliconcloud', mode: 'includes' }, + { keyword: 'siliconflow', mode: 'includes' }, + ], + }, + { + name: 'Azure AI', + icon: 'azureai-color', + color: 'linear-gradient(135deg, #0284c7, #2563eb)', + rules: [ + { keyword: 'azureai', mode: 'includes' }, + { keyword: 'azure-openai', mode: 'includes' }, + { keyword: 'azure/openai', mode: 'includes' }, + ], + }, + { + name: 'AWS Bedrock', + icon: 'bedrock-color', + color: 'linear-gradient(135deg, #f59e0b, #f97316)', + rules: [ + { keyword: 'bedrock', mode: 'includes' }, + ], + }, + { + name: 'Vertex AI', + icon: 'vertexai-color', + color: 'linear-gradient(135deg, #2563eb, #6366f1)', + rules: [ + { keyword: 'vertexai', mode: 'includes' }, + ], + }, + { + name: 'Google Cloud', + icon: 'googlecloud-brand-color', + color: 'linear-gradient(135deg, #4285f4, #34a853)', + rules: [ + { keyword: 'googlecloud', mode: 'includes' }, + { keyword: 'google-cloud', mode: 'includes' }, + ], + }, + { + name: '百度智能云', + icon: 'baiducloud-color', + color: 'linear-gradient(135deg, #2563eb, #38bdf8)', + rules: [ + { keyword: 'baiducloud', mode: 'includes' }, + { keyword: 'qianfan', mode: 'includes' }, + ], + }, + { + name: '百炼', + icon: 'bailian-color', + color: 'linear-gradient(135deg, #7c3aed, #2563eb)', + rules: [ + { keyword: 'bailian', mode: 'includes' }, + { keyword: 'dashscope', mode: 'includes' }, + ], + }, + { + name: '阿里云', + icon: 'alibabacloud-color', + color: 'linear-gradient(135deg, #f97316, #fb923c)', + rules: [ + { keyword: 'alibabacloud', mode: 'includes' }, + ], + }, + { + name: '火山引擎', + icon: 'volcengine-color', + color: 'linear-gradient(135deg, #325ab4, #0f66ff)', + rules: [ + { keyword: 'volcengine', mode: 'includes' }, + ], + }, + { + name: '七牛云', + icon: 'qiniu-color', + color: 'linear-gradient(135deg, #06b6d4, #0891b2)', + rules: [ + { keyword: 'qiniu', mode: 'includes' }, + ], + }, +]; + +function normalizeInput(value: string): string { + return String(value || '').trim().toLowerCase(); +} + +export function stripCommonWrappers(value: string): string { + return value + .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') + .replace(/^re:\s*/g, '') + .replace(/^\^+/, '') + .replace(/\$+$/, '') + .trim(); +} + +export 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): BrandMatchContext { + 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 matchesRule(context: BrandMatchContext, rule: BrandRule): boolean { + switch (rule.mode) { + case 'includes': + return context.raw.includes(rule.keyword) + || context.cleaned.includes(rule.keyword) + || context.candidates.some((candidate) => candidate.includes(rule.keyword)); + case 'startsWith': + return context.raw.startsWith(rule.keyword) + || context.cleaned.startsWith(rule.keyword) + || context.segments.some((segment) => segment.startsWith(rule.keyword)) + || context.candidates.some((candidate) => candidate.startsWith(rule.keyword)); + case 'segment': + return context.segments.includes(rule.keyword); + case 'boundary': { + const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`); + return pattern.test(context.raw) + || pattern.test(context.cleaned) + || context.candidates.some((candidate) => pattern.test(candidate)); + } + default: + return false; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({ + brand, + boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`), +})); + +export function getAllBrands(): BrandInfo[] { + return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color })); +} + +export function getAllBrandNames(): string[] { + return BRAND_DEFINITIONS.map((brand) => brand.name); +} + +export function getBrand(modelName: string): BrandInfo | null { + const context = buildMatchContext(modelName); + + for (const definition of BRAND_DEFINITIONS) { + if (definition.rules.some((rule) => matchesRule(context, rule))) { + return { + name: definition.name, + icon: definition.icon, + color: definition.color, + }; + } + } + + 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; +} diff --git a/src/web/components/BrandIcon.test.ts b/src/web/components/BrandIcon.test.ts index 1081dd2..66ba08f 100644 --- a/src/web/components/BrandIcon.test.ts +++ b/src/web/components/BrandIcon.test.ts @@ -47,6 +47,47 @@ describe('getBrand', () => { expect(getBrand('jina-embeddings-v3')).toMatchObject({ name: 'Jina AI', icon: expect.any(String) }); }); + it('detects new provider and platform brands with lobe-hub icons', () => { + expect(getBrand('openrouter/openrouter-auto')).toMatchObject({ name: 'OpenRouter', icon: expect.any(String) }); + expect(getBrand('groq/compound-beta')).toMatchObject({ name: 'Groq', icon: expect.any(String) }); + expect(getBrand('deepinfra/deepinfra-chat')).toMatchObject({ name: 'DeepInfra', icon: expect.any(String) }); + expect(getBrand('fireworks-ai/firefunction-v2')).toMatchObject({ name: 'Fireworks', icon: expect.any(String) }); + expect(getBrand('together/together-chat')).toMatchObject({ name: 'Together AI', icon: expect.any(String) }); + expect(getBrand('replicate/recraft-v3')).toMatchObject({ name: 'Replicate', icon: expect.any(String) }); + expect(getBrand('cerebras/cerebras-chat')).toMatchObject({ name: 'Cerebras', icon: expect.any(String) }); + expect(getBrand('jamba-1.5-mini')).toMatchObject({ name: 'AI21 Labs', icon: expect.any(String) }); + expect(getBrand('baichuan-m1-14b-instruct')).toMatchObject({ name: '百川智能', icon: expect.any(String) }); + expect(getBrand('sensenova/sensenova-v6')).toMatchObject({ name: 'SenseNova', icon: expect.any(String) }); + expect(getBrand('bedrock/us.amazon.nova-pro-v1:0')).toMatchObject({ name: 'Amazon Nova', icon: expect.any(String) }); + expect(getBrand('amazon/nova-premier-v1')).toMatchObject({ name: 'Amazon Nova', icon: expect.any(String) }); + expect(getBrand('dashscope/wanx2.1-t2i-turbo')).toMatchObject({ name: '百炼', icon: expect.any(String) }); + }); + + it('recognizes user-reported uncategorized brands when current lobe-hub icons exist', () => { + expect(getBrand('relace/relace-search')).toMatchObject({ name: 'Relace', icon: expect.any(String) }); + expect(getBrand('xiaomi/mimo-v2-pro')).toMatchObject({ name: 'Xiaomi MiMo', icon: expect.any(String) }); + expect(getBrand('arcee-ai/trinity-mini')).toMatchObject({ name: 'Arcee', icon: expect.any(String) }); + expect(getBrand('aion-labs/aion-2.0')).toMatchObject({ name: 'AionLabs', icon: expect.any(String) }); + expect(getBrand('allenai/olmo-3-32b-think')).toMatchObject({ name: 'AI2', icon: expect.any(String) }); + expect(getBrand('deepcogito/cogito-v2.1-671b')).toMatchObject({ name: 'DeepCogito', icon: expect.any(String) }); + expect(getBrand('essentialai/rnj-1-instruct')).toMatchObject({ name: 'Essential AI', icon: expect.any(String) }); + expect(getBrand('inflection/inflection-3-pi')).toMatchObject({ name: 'Inflection', icon: expect.any(String) }); + expect(getBrand('liquid/lfm-2-24b-a2b')).toMatchObject({ name: 'Liquid AI', icon: expect.any(String) }); + expect(getBrand('LongCat-Flash-Lite')).toMatchObject({ name: 'LongCat', icon: expect.any(String) }); + expect(getBrand('morph/morph-v3-fast')).toMatchObject({ name: 'Morph', icon: expect.any(String) }); + expect(getBrand('nousresearch/hermes-4-70b')).toMatchObject({ name: 'Nous Research', icon: expect.any(String) }); + expect(getBrand('inception/mercury')).toMatchObject({ name: 'Inception', icon: expect.any(String) }); + expect(getBrand('upstage/solar-pro-3')).toMatchObject({ name: 'Upstage', icon: expect.any(String) }); + expect(getBrand('2zai')).toMatchObject({ name: 'Z.ai', icon: expect.any(String) }); + }); + + it('prefers intrinsic model vendors over upstream hosting platforms', () => { + expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic'); + expect(getBrand('deepinfra/meta-llama/llama-3.3-70b-instruct')?.name).toBe('Meta'); + expect(getBrand('vertexai/google/gemini-2.5-pro')?.name).toBe('Google'); + expect(getBrand('azureai/gpt-4o')?.name).toBe('OpenAI'); + }); + it('returns null for unknown model names', () => { expect(getBrand('totally-unknown-model')).toBeNull(); }); diff --git a/src/web/components/brandRegistry.ts b/src/web/components/brandRegistry.ts index 5be2e35..762b29c 100644 --- a/src/web/components/brandRegistry.ts +++ b/src/web/components/brandRegistry.ts @@ -1,22 +1,12 @@ -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; -}; +export { + collectBrandCandidates, + getAllBrandNames, + getAllBrands, + getBrand, + stripCommonWrappers, + type BrandInfo, + type BrandMatchContext, +} from '../../server/shared/modelBrand.js'; const LEGACY_ICON_ALIASES: Record = { anthropic: 'claude-color', @@ -45,285 +35,6 @@ 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, '\\$&'); -} - -const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({ - brand, - boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`), -})); - const FALLBACK_COLORS = [ 'linear-gradient(135deg, #4f46e5, #818cf8)', 'linear-gradient(135deg, #059669, #34d399)', @@ -347,38 +58,10 @@ export function getBrandIconUrl(icon: string | null | undefined, cdn: string): s return `${cdn}/${normalized}.png`; } -export function getBrand(modelName: string): BrandInfo | null { - const context = buildMatchContext(modelName); - - for (const definition of BRAND_DEFINITIONS) { - if (definition.match(context)) { - return { - name: definition.name, - icon: definition.icon, - color: definition.color, - }; - } - } - - 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; -} - 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]; + return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]!; } export function avatarLetters(name: string): string { diff --git a/src/web/pages/Models.marketplace-text.test.tsx b/src/web/pages/Models.marketplace-text.test.tsx index e3d46f0..89b7437 100644 --- a/src/web/pages/Models.marketplace-text.test.tsx +++ b/src/web/pages/Models.marketplace-text.test.tsx @@ -195,6 +195,214 @@ describe('Models marketplace text', () => { } }); + it('shows newly added provider fallback brands without losing vendor brands in the filter panel', async () => { + apiMock.getModelsMarketplace.mockResolvedValue({ + models: [ + { + name: 'openrouter/openrouter-auto', + accountCount: 1, + tokenCount: 1, + avgLatency: 180, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 1, + site: '平台站 A', + username: 'tester', + latency: 180, + balance: 9.9, + tokens: [{ id: 1, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'deepinfra/meta-llama/llama-3.3-70b-instruct', + accountCount: 1, + tokenCount: 1, + avgLatency: 240, + successRate: 98, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 2, + site: '平台站 B', + username: 'tester', + latency: 240, + balance: 7.2, + tokens: [{ id: 2, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'groq/compound-beta', + accountCount: 1, + tokenCount: 1, + avgLatency: 95, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 3, + site: '平台站 C', + username: 'tester', + latency: 95, + balance: 5.1, + tokens: [{ id: 3, name: 'default', isDefault: true }], + }, + ], + }, + ], + }); + + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const text = collectText(root!.root); + expect(text).toContain('OpenRouter'); + expect(text).toContain('Meta'); + expect(text).toContain('Groq'); + expect(text).not.toContain('其他未归类的模型'); + } finally { + root?.unmount(); + } + }); + + it('shows user-reported recognizable brands in the marketplace filter panel', async () => { + apiMock.getModelsMarketplace.mockResolvedValue({ + models: [ + { + name: 'xiaomi/mimo-v2-pro', + accountCount: 1, + tokenCount: 1, + avgLatency: 180, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 1, + site: '平台站 A', + username: 'tester', + latency: 180, + balance: 9.9, + tokens: [{ id: 1, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'arcee-ai/trinity-mini', + accountCount: 1, + tokenCount: 1, + avgLatency: 240, + successRate: 98, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 2, + site: '平台站 B', + username: 'tester', + latency: 240, + balance: 7.2, + tokens: [{ id: 2, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'amazon/nova-premier-v1', + accountCount: 1, + tokenCount: 1, + avgLatency: 95, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 3, + site: '平台站 C', + username: 'tester', + latency: 95, + balance: 5.1, + tokens: [{ id: 3, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'LongCat-Flash-Lite', + accountCount: 1, + tokenCount: 1, + avgLatency: 95, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 4, + site: '平台站 D', + username: 'tester', + latency: 95, + balance: 5.1, + tokens: [{ id: 4, name: 'default', isDefault: true }], + }, + ], + }, + ], + }); + + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const text = collectText(root!.root); + expect(text).toContain('Xiaomi MiMo'); + expect(text).toContain('Arcee'); + expect(text).toContain('Amazon Nova'); + expect(text).toContain('LongCat'); + expect(text).not.toContain('其他未归类的模型'); + } finally { + root?.unmount(); + } + }); + it('keeps a visible mobile filter entry on small screens', async () => { const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis; nextWindow.innerWidth = 768; From 88f40e91f71df360d16c630659491dd31953a797 Mon Sep 17 00:00:00 2001 From: cita-777 Date: Fri, 17 Apr 2026 19:41:14 +0800 Subject: [PATCH 02/13] Preserve the active worktree snapshot on dev for follow-on integration The root checkout already contained a large in-progress proxy, transformer, and settings refactor. This commit captures that exact work on a dedicated local dev branch without disturbing the original dirty worktree, so the changes can be continued or split from dev instead of living only as uncommitted state. Constraint: The source checkout was already dirty on another branch, so the move had to preserve that worktree while excluding local .idea and .omx residue from the commit Rejected: Switch the dirty root checkout directly to dev | risked disrupting the original branch and in-progress state Confidence: medium Scope-risk: broad Reversibility: clean Directive: Treat this as a transfer snapshot; fix the failing oauth/accountTokens/normalized/docs test regressions before merge or publication Tested: git diff --check; npm run typecheck; npm run repo:drift-check Not-tested: Clean test pass; npm test currently fails in oauth routes, accountTokens sync, shared normalized helpers, gemini route expectations, architecture-boundaries, and docs vitepress config --- docker/Dockerfile | 5 +- scripts/dev/docker.workflow.test.ts | 7 + .../dev/update-readme-contributors.test.ts | 111 ++ scripts/dev/update-readme-contributors.ts | 174 +++ .../capabilities/responsesCompact.test.ts | 101 ++ .../capabilities/responsesCompact.ts | 54 +- .../proxy-core/cliProfiles/codexProfile.ts | 105 +- .../proxy-core/cliProfiles/registry.test.ts | 23 + .../providers/claudeProviderProfile.ts | 11 +- .../proxy-core/providers/headerUtils.test.ts | 1 + .../proxy-core/providers/headerUtils.ts | 9 +- .../proxy-core/providers/registry.test.ts | 59 +- .../runtime/codexSessionResponseStore.test.ts | 42 +- .../runtime/codexSessionResponseStore.ts | 56 +- .../runtime/codexWebsocketRuntime.test.ts | 64 +- .../runtime/codexWebsocketRuntime.ts | 6 +- .../runtime}/responsesSseFinal.test.ts | 0 .../runtime}/responsesSseFinal.ts | 2 +- src/server/proxy-core/surfaces/chatSurface.ts | 43 +- .../proxy-core/surfaces/geminiSurface.ts | 4 +- .../surfaces/openAiResponsesSurface.ts | 96 +- .../proxy-core/surfaces/sharedSurface.test.ts | 8 +- .../proxy-core/surfaces/sharedSurface.ts | 4 +- .../sharedSurface.usage-source.test.ts | 4 +- src/server/routes/api/settings.events.test.ts | 102 ++ src/server/routes/api/settings.ts | 21 + src/server/routes/api/stats.ts | 61 +- .../routes/proxy/chat.codex-oauth.test.ts | 4 +- .../routes/proxy/chat.count-tokens.test.ts | 35 + .../routes/proxy/chat.siteApiEndpoint.test.ts | 6 +- src/server/routes/proxy/chat.stream.test.ts | 511 ++++++-- src/server/routes/proxy/chat.ts | 2 + src/server/routes/proxy/completions.ts | 2 +- .../proxy/downstreamClientContext.test.ts | 16 + src/server/routes/proxy/gemini.test.ts | 103 +- src/server/routes/proxy/logPathMeta.ts | 56 +- src/server/routes/proxy/proxyBilling.ts | 93 +- .../proxy/responses.codex-oauth.test.ts | 199 ++- .../proxy/responses.compact-upstream.test.ts | 273 +++- src/server/routes/proxy/responses.ts | 22 + .../routes/proxy/responses.websocket.test.ts | 136 ++ src/server/routes/proxy/responsesWebsocket.ts | 2 + src/server/routes/proxy/routeAliases.test.ts | 112 ++ .../routes/proxy/upstreamEndpoint.test.ts | 150 ++- src/server/routes/proxy/upstreamEndpoint.ts | 1093 +--------------- src/server/services/oauth/codexProvider.ts | 5 +- src/server/services/payloadRules.test.ts | 89 ++ src/server/services/payloadRules.ts | 171 +++ .../proxy => services}/proxyBilling.test.ts | 46 +- src/server/services/proxyBilling.ts | 92 ++ .../proxyFailureJudge.test.ts | 2 +- .../proxy => services}/proxyFailureJudge.ts | 4 +- .../proxyLogMessage.test.ts} | 2 +- src/server/services/proxyLogMessage.ts | 51 + src/server/services/runtimeModelProbe.test.ts | 18 +- .../upstreamEndpointDerivation.test.ts | 164 +++ .../services/upstreamEndpointDerivation.ts | 322 +++++ .../services/upstreamEndpointRuntime.ts | 8 +- .../services/upstreamEndpointRuntimeMemory.ts | 51 +- .../services/upstreamRequestBuilder.test.ts | 137 ++ src/server/services/upstreamRequestBuilder.ts | 830 ++++++++++++ src/server/shared/codexClientFamily.test.ts | 44 + src/server/shared/codexClientFamily.ts | 166 +++ .../anthropic/messages/compatibility.ts | 5 +- .../anthropic/messages/index.test.ts | 13 + .../transformers/anthropic/messages/index.ts | 59 +- .../anthropic/messages/outbound.ts | 188 +-- .../anthropic/messages/requestBridge.test.ts | 58 + .../anthropic/messages/requestBridge.ts | 47 + .../anthropic/messages/responseBridge.test.ts | 225 ++++ .../anthropic/messages/responseBridge.ts | 273 ++++ .../transformers/anthropic/messages/stream.ts | 1122 +--------------- .../anthropic/messages/streamBridge.test.ts | 169 +++ .../anthropic/messages/streamBridge.ts | 1126 +++++++++++++++++ .../canonical/continuationBridge.test.ts | 79 ++ .../canonical/continuationBridge.ts | 94 ++ src/server/transformers/canonical/envelope.ts | 77 ++ .../canonical/openAiRequestBridge.test.ts | 61 + .../canonical/openAiRequestBridge.ts | 564 +++++++++ .../transformers/canonical/request.test.ts | 92 ++ src/server/transformers/canonical/request.ts | 603 +-------- .../gemini/generate-content/cliBridge.test.ts | 58 + .../gemini/generate-content/cliBridge.ts | 94 ++ .../gemini/generate-content/index.ts | 249 +--- .../gemini/generate-content/outbound.ts | 288 +---- .../generate-content/requestBridge.test.ts | 265 ++++ .../requestBridge.thoughtSignature.test.ts} | 36 +- .../gemini/generate-content/requestBridge.ts} | 369 ++++-- .../generate-content/responseBridge.test.ts | 98 ++ .../gemini/generate-content/responseBridge.ts | 287 +++++ .../gemini/generate-content/stream.ts | 244 +--- .../generate-content/streamBridge.test.ts | 40 + .../gemini/generate-content/streamBridge.ts | 253 ++++ .../transformers/openai/chat/index.test.ts | 159 +++ src/server/transformers/openai/chat/index.ts | 61 +- .../transformers/openai/chat/outbound.ts | 196 +-- .../transformers/openai/chat/proxyStream.ts | 16 +- .../openai/chat/requestBridge.test.ts | 115 ++ .../transformers/openai/chat/requestBridge.ts | 45 + .../openai/chat/responseBridge.test.ts | 161 +++ .../openai/chat/responseBridge.ts | 210 +++ src/server/transformers/openai/chat/stream.ts | 191 +-- .../openai/chat/streamBridge.test.ts | 196 +++ .../transformers/openai/chat/streamBridge.ts | 218 ++++ .../openai/responses/aggregator.ts | 2 +- .../responses/codexCompatibility.test.ts | 28 +- .../openai/responses/codexCompatibility.ts | 90 +- .../openai/responses/compatibility.ts | 132 +- .../openai/responses/conversion.test.ts | 187 +++ .../openai/responses/conversion.ts | 23 +- .../openai/responses/index.test.ts | 35 + .../transformers/openai/responses/index.ts | 76 +- .../transformers/openai/responses/outbound.ts | 459 +------ .../openai/responses/proxyStream.ts | 156 +-- .../openai/responses/requestBridge.test.ts | 126 ++ .../openai/responses/requestBridge.ts | 87 ++ .../openai/responses/responseBridge.test.ts | 217 ++++ .../openai/responses/responseBridge.ts | 453 +++++++ .../openai/responses/routeCompatibility.ts | 38 +- .../transformers/openai/responses/stream.ts | 84 +- .../openai/responses/streamBridge.test.ts | 420 ++++++ .../openai/responses/streamBridge.ts | 305 +++++ .../shared/chatFormatsCore.test.ts | 77 ++ .../transformers/shared/chatFormatsCore.ts | 54 +- .../shared/endpointCompatibility.test.ts | 77 ++ .../shared/endpointCompatibility.ts | 218 +++- src/web/api.ts | 1 + src/web/index.css | 39 +- src/web/pages/Settings.tsx | 520 ++++++++ src/web/pages/TokenRoutes.tsx | 8 +- src/web/pages/settings.payload-rules.test.tsx | 369 ++++++ src/web/pages/settings/payloadRulesVisual.ts | 273 ++++ .../tokenRoutes.desktop-detail-panel.test.tsx | 2 +- 133 files changed, 13780 insertions(+), 5670 deletions(-) create mode 100644 scripts/dev/update-readme-contributors.test.ts create mode 100644 scripts/dev/update-readme-contributors.ts create mode 100644 src/server/proxy-core/capabilities/responsesCompact.test.ts rename src/server/{routes/proxy => proxy-core/runtime}/responsesSseFinal.test.ts (100%) rename src/server/{routes/proxy => proxy-core/runtime}/responsesSseFinal.ts (99%) create mode 100644 src/server/routes/proxy/routeAliases.test.ts create mode 100644 src/server/services/payloadRules.test.ts rename src/server/{routes/proxy => services}/proxyBilling.test.ts (57%) create mode 100644 src/server/services/proxyBilling.ts rename src/server/{routes/proxy => services}/proxyFailureJudge.test.ts (98%) rename src/server/{routes/proxy => services}/proxyFailureJudge.ts (98%) rename src/server/{routes/proxy/logPathMeta.test.ts => services/proxyLogMessage.test.ts} (96%) create mode 100644 src/server/services/proxyLogMessage.ts create mode 100644 src/server/services/upstreamEndpointDerivation.test.ts create mode 100644 src/server/services/upstreamEndpointDerivation.ts create mode 100644 src/server/services/upstreamRequestBuilder.test.ts create mode 100644 src/server/services/upstreamRequestBuilder.ts create mode 100644 src/server/shared/codexClientFamily.test.ts create mode 100644 src/server/shared/codexClientFamily.ts create mode 100644 src/server/transformers/anthropic/messages/requestBridge.test.ts create mode 100644 src/server/transformers/anthropic/messages/requestBridge.ts create mode 100644 src/server/transformers/anthropic/messages/responseBridge.test.ts create mode 100644 src/server/transformers/anthropic/messages/responseBridge.ts create mode 100644 src/server/transformers/anthropic/messages/streamBridge.test.ts create mode 100644 src/server/transformers/anthropic/messages/streamBridge.ts create mode 100644 src/server/transformers/canonical/continuationBridge.test.ts create mode 100644 src/server/transformers/canonical/continuationBridge.ts create mode 100644 src/server/transformers/canonical/envelope.ts create mode 100644 src/server/transformers/canonical/openAiRequestBridge.test.ts create mode 100644 src/server/transformers/canonical/openAiRequestBridge.ts create mode 100644 src/server/transformers/gemini/generate-content/cliBridge.test.ts create mode 100644 src/server/transformers/gemini/generate-content/cliBridge.ts create mode 100644 src/server/transformers/gemini/generate-content/requestBridge.test.ts rename src/server/{routes/proxy/geminiCliCompat.thoughtSignature.test.ts => transformers/gemini/generate-content/requestBridge.thoughtSignature.test.ts} (83%) rename src/server/{routes/proxy/geminiCliCompat.ts => transformers/gemini/generate-content/requestBridge.ts} (53%) create mode 100644 src/server/transformers/gemini/generate-content/responseBridge.test.ts create mode 100644 src/server/transformers/gemini/generate-content/responseBridge.ts create mode 100644 src/server/transformers/gemini/generate-content/streamBridge.test.ts create mode 100644 src/server/transformers/gemini/generate-content/streamBridge.ts create mode 100644 src/server/transformers/openai/chat/requestBridge.test.ts create mode 100644 src/server/transformers/openai/chat/requestBridge.ts create mode 100644 src/server/transformers/openai/chat/responseBridge.test.ts create mode 100644 src/server/transformers/openai/chat/responseBridge.ts create mode 100644 src/server/transformers/openai/chat/streamBridge.test.ts create mode 100644 src/server/transformers/openai/chat/streamBridge.ts create mode 100644 src/server/transformers/openai/responses/requestBridge.test.ts create mode 100644 src/server/transformers/openai/responses/requestBridge.ts create mode 100644 src/server/transformers/openai/responses/responseBridge.test.ts create mode 100644 src/server/transformers/openai/responses/responseBridge.ts create mode 100644 src/server/transformers/openai/responses/streamBridge.test.ts create mode 100644 src/server/transformers/openai/responses/streamBridge.ts create mode 100644 src/server/transformers/shared/endpointCompatibility.test.ts create mode 100644 src/web/pages/settings.payload-rules.test.tsx create mode 100644 src/web/pages/settings/payloadRulesVisual.ts diff --git a/docker/Dockerfile b/docker/Dockerfile index 294bfd8..67ce2ea 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,3 @@ -# syntax=docker/dockerfile:1.7 # Keep the Docker base on Node 22 because the official Node 24/25 slim images # no longer publish linux/arm/v7 manifests, which breaks our armv7 Docker jobs. FROM node:22-bookworm-slim AS builder @@ -12,12 +11,12 @@ RUN apt-get update \ ENV PYTHON=/usr/bin/python3 COPY package.json package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts --no-audit --no-fund +RUN npm ci --ignore-scripts --no-audit --no-fund RUN npm rebuild esbuild sharp better-sqlite3 --no-audit --no-fund COPY . . RUN npm run build:web && npm run build:server -RUN --mount=type=cache,target=/root/.npm npm prune --omit=dev --no-audit --no-fund +RUN npm prune --omit=dev --no-audit --no-fund FROM node:22-bookworm-slim diff --git a/scripts/dev/docker.workflow.test.ts b/scripts/dev/docker.workflow.test.ts index 8805c8d..716f36e 100644 --- a/scripts/dev/docker.workflow.test.ts +++ b/scripts/dev/docker.workflow.test.ts @@ -34,6 +34,13 @@ describe('docker workflows', () => { expect(dockerfile).toContain('FROM node:22-bookworm-slim'); }); + it('avoids buildkit-only frontend syntax so managed docker builders can parse it reliably', () => { + const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8'); + + expect(dockerfile).not.toContain('# syntax=docker/dockerfile:'); + expect(dockerfile).not.toContain('RUN --mount=type=cache'); + }); + it('keeps server docker builds isolated from desktop packaging dependencies', () => { const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8'); diff --git a/scripts/dev/update-readme-contributors.test.ts b/scripts/dev/update-readme-contributors.test.ts new file mode 100644 index 0000000..6699097 --- /dev/null +++ b/scripts/dev/update-readme-contributors.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeContributors, + replaceContributorsSection, + renderContributorsBlock, +} from './update-readme-contributors.js'; + +describe('README contributors updater', () => { + it('filters anonymous and bot contributors before rendering', () => { + const contributors = normalizeContributors([ + { + login: 'cita-777', + html_url: 'https://github.com/cita-777', + avatar_url: 'https://avatars.githubusercontent.com/u/177306803?v=4', + contributions: 483, + }, + { + login: 'dependabot[bot]', + html_url: 'https://github.com/apps/dependabot', + avatar_url: 'https://avatars.githubusercontent.com/in/29110?v=4', + contributions: 12, + }, + { + login: null, + html_url: null, + avatar_url: null, + contributions: 22, + }, + { + login: 'Babylonehy', + html_url: 'https://github.com/Babylonehy', + avatar_url: 'https://avatars.githubusercontent.com/u/30937892?v=4', + contributions: 3, + }, + ]); + + expect(contributors).toEqual([ + { + login: 'cita-777', + htmlUrl: 'https://github.com/cita-777', + avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48', + }, + { + login: 'Babylonehy', + htmlUrl: 'https://github.com/Babylonehy', + avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48', + }, + ]); + }); + + it('replaces the marked README section with an avatar wall', () => { + const readme = [ + '# Metapi', + '', + '', + 'old block', + '', + '', + 'tail', + ].join('\n'); + + const next = replaceContributorsSection(readme, [ + { + login: 'cita-777', + htmlUrl: 'https://github.com/cita-777', + avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48', + }, + { + login: 'Hureru', + htmlUrl: 'https://github.com/Hureru', + avatarUrl: 'https://avatars.githubusercontent.com/u/121702350?v=4&s=48', + }, + { + login: 'Babylonehy', + htmlUrl: 'https://github.com/Babylonehy', + avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48', + }, + ], 2); + + expect(next).toContain('

'); + expect(next).toContain('https://github.com/cita-777'); + expect(next).toContain('https://github.com/Hureru'); + expect(next).toContain('https://github.com/Babylonehy'); + expect(next).not.toContain('old block'); + expect(next).toContain('tail'); + }); + + it('renders contributors across multiple rows', () => { + const html = renderContributorsBlock([ + { + login: 'cita-777', + htmlUrl: 'https://github.com/cita-777', + avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48', + }, + { + login: 'Hureru', + htmlUrl: 'https://github.com/Hureru', + avatarUrl: 'https://avatars.githubusercontent.com/u/121702350?v=4&s=48', + }, + { + login: 'Babylonehy', + htmlUrl: 'https://github.com/Babylonehy', + avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48', + }, + ], 2); + + expect(html).toContain('\n (); + + return contributors + .filter((contributor) => { + const login = contributor.login?.trim(); + const htmlUrl = contributor.html_url?.trim(); + const avatarUrl = contributor.avatar_url?.trim(); + + if (!login || !htmlUrl || !avatarUrl) { + return false; + } + + if (EXCLUDED_LOGINS.has(login.toLowerCase()) || login.toLowerCase().endsWith('[bot]')) { + return false; + } + + return true; + }) + .sort((left, right) => (right.contributions ?? 0) - (left.contributions ?? 0)) + .filter((contributor) => { + const key = contributor.login!.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }) + .map((contributor) => ({ + login: contributor.login!.trim(), + htmlUrl: contributor.html_url!.trim(), + avatarUrl: normalizeAvatarUrl(contributor.avatar_url!.trim()), + })); +} + +export function renderContributorsBlock( + contributors: ReadmeContributor[], + perLine = PER_LINE, +): string { + if (contributors.length === 0) { + return '

\n No public contributors found yet.\n

'; + } + + const lines: string[] = []; + + for (let index = 0; index < contributors.length; index += perLine) { + const chunk = contributors.slice(index, index + perLine); + lines.push( + ` ${chunk + .map( + (contributor) => + `${contributor.login}`, + ) + .join(' ')}`, + ); + } + + return `

\n${lines.join('\n')}\n

`; +} + +export function replaceContributorsSection( + readme: string, + contributors: ReadmeContributor[], + perLine = PER_LINE, +): string { + const start = readme.indexOf(START_MARKER); + const end = readme.indexOf(END_MARKER); + + if (start === -1 || end === -1 || end < start) { + throw new Error('README contributors markers are missing'); + } + + const before = readme.slice(0, start + START_MARKER.length); + const after = readme.slice(end); + return `${before}\n${renderContributorsBlock(contributors, perLine)}\n${after}`; +} + +export function updateReadmeFile( + readmePath: string, + contributors: ReadmeContributor[], + perLine = PER_LINE, +): boolean { + const current = readFileSync(readmePath, 'utf8'); + const next = replaceContributorsSection(current, contributors, perLine); + if (current === next) { + return false; + } + + writeFileSync(readmePath, next); + return true; +} + +export function fetchContributors(repo = REPO): ReadmeContributor[] { + const raw = execFileSync( + 'gh', + ['api', `repos/${repo}/contributors?per_page=100&anon=1`, '--paginate'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + return normalizeContributors(parsePaginatedJson(raw) as GitHubContributor[]); +} + +function parsePaginatedJson(raw: string): unknown[] { + const items: unknown[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + items.push(...parsed); + } else { + items.push(parsed); + } + } + + return items; +} + +function normalizeAvatarUrl(url: string): string { + if (!/^https?:/i.test(url)) { + return url; + } + + const lower = url.toLowerCase(); + if (lower.includes('s=') || lower.includes('size=')) { + return url; + } + + return `${url}${url.includes('?') ? '&' : '?'}s=48`; +} + +function main(): void { + const contributors = fetchContributors(REPO); + const updatedFiles = README_FILES.filter((path) => + updateReadmeFile(resolve(process.cwd(), path), contributors), + ); + + const touched = updatedFiles.length > 0 ? updatedFiles.join(', ') : 'none'; + console.log(`Updated README contributors for ${contributors.length} people: ${touched}`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + main(); +} diff --git a/src/server/proxy-core/capabilities/responsesCompact.test.ts b/src/server/proxy-core/capabilities/responsesCompact.test.ts new file mode 100644 index 0000000..ab38975 --- /dev/null +++ b/src/server/proxy-core/capabilities/responsesCompact.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { + ensureCompactResponsesJsonAcceptHeader, + sanitizeCompactResponsesRequestBody, + shouldForceResponsesUpstreamStream, +} from './responsesCompact.js'; + +describe('sanitizeCompactResponsesRequestBody', () => { + it('removes stream fields and store from codex compact requests', () => { + expect(sanitizeCompactResponsesRequestBody({ + stream: true, + stream_options: { include_obfuscation: true }, + store: false, + input: 'hello', + }, { + sitePlatform: 'codex', + })).toEqual({ + input: 'hello', + }); + }); + + it('removes store from sub2api compact requests', () => { + expect(sanitizeCompactResponsesRequestBody({ + stream: true, + stream_options: { include_obfuscation: true }, + store: false, + input: 'hello', + }, { + sitePlatform: 'sub2api', + })).toEqual({ + input: 'hello', + }); + }); + + it('keeps store for unrelated compact platforms while still removing stream fields', () => { + expect(sanitizeCompactResponsesRequestBody({ + stream: true, + stream_options: { include_obfuscation: true }, + store: false, + input: 'hello', + }, { + sitePlatform: 'openai', + })).toEqual({ + store: false, + input: 'hello', + }); + }); +}); + +describe('shouldForceResponsesUpstreamStream', () => { + it('forces non-compact responses streaming for codex and sub2api', () => { + expect(shouldForceResponsesUpstreamStream({ + sitePlatform: 'codex', + isCompactRequest: false, + })).toBe(true); + expect(shouldForceResponsesUpstreamStream({ + sitePlatform: 'sub2api', + isCompactRequest: false, + })).toBe(true); + }); + + it('does not force compact or unrelated platforms', () => { + expect(shouldForceResponsesUpstreamStream({ + sitePlatform: 'codex', + isCompactRequest: true, + })).toBe(false); + expect(shouldForceResponsesUpstreamStream({ + sitePlatform: 'openai', + isCompactRequest: false, + })).toBe(false); + }); +}); + +describe('ensureCompactResponsesJsonAcceptHeader', () => { + it('adds application/json accept for codex and sub2api compact requests when missing', () => { + expect(ensureCompactResponsesJsonAcceptHeader({}, { + sitePlatform: 'codex', + })).toEqual({ + accept: 'application/json', + }); + expect(ensureCompactResponsesJsonAcceptHeader({}, { + sitePlatform: 'sub2api', + })).toEqual({ + accept: 'application/json', + }); + }); + + it('preserves existing accept values and unrelated platforms', () => { + expect(ensureCompactResponsesJsonAcceptHeader({ + Accept: 'application/json', + }, { + sitePlatform: 'codex', + })).toEqual({ + Accept: 'application/json', + }); + expect(ensureCompactResponsesJsonAcceptHeader({}, { + sitePlatform: 'openai', + })).toEqual({}); + }); +}); diff --git a/src/server/proxy-core/capabilities/responsesCompact.ts b/src/server/proxy-core/capabilities/responsesCompact.ts index a97555b..9596761 100644 --- a/src/server/proxy-core/capabilities/responsesCompact.ts +++ b/src/server/proxy-core/capabilities/responsesCompact.ts @@ -2,35 +2,81 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function shouldStripCompactResponsesStore(sitePlatform?: string): boolean { + const normalized = asTrimmedString(sitePlatform).toLowerCase(); + return normalized === 'codex' || normalized === 'sub2api'; +} + +function shouldForceCompactResponsesJsonAccept(sitePlatform?: string): boolean { + return shouldStripCompactResponsesStore(sitePlatform); +} + +export function shouldForceResponsesUpstreamStream(input: { + sitePlatform?: string; + isCompactRequest?: boolean; +}): boolean { + if (input.isCompactRequest) return false; + const sitePlatform = asTrimmedString(input.sitePlatform).toLowerCase(); + return sitePlatform === 'codex' || sitePlatform === 'sub2api'; +} + export function sanitizeCompactResponsesRequestBody( body: Record, + options?: { + sitePlatform?: string; + }, ): Record { const next = { ...body }; delete next.stream; delete next.stream_options; + if (shouldStripCompactResponsesStore(options?.sitePlatform)) { + delete next.store; + } return next; } +export function ensureCompactResponsesJsonAcceptHeader( + headers: Record, + options?: { + sitePlatform?: string; + }, +): Record { + if (!shouldForceCompactResponsesJsonAccept(options?.sitePlatform)) return headers; + const existingAccept = asTrimmedString(headers.accept) || asTrimmedString(headers.Accept); + if (existingAccept) return headers; + return { + ...headers, + accept: 'application/json', + }; +} + export function shouldFallbackCompactResponsesToResponses(input: { status?: number; rawErrText?: string; + requestPath?: string; }): boolean { const status = Number.isFinite(Number(input.status)) ? Number(input.status) : 0; const compact = asTrimmedString(input.rawErrText).toLowerCase(); - const hasCompactHint = ( + const requestPath = asTrimmedString(input.requestPath).toLowerCase(); + const hasRawCompactHint = ( compact.includes('/responses/compact') || compact.includes('responses/compact') || compact.includes('compact endpoint') || /(^|[^a-z])compact([^a-z]|$)/.test(compact) ); + const hasCompactRequestPathHint = ( + requestPath.endsWith('/responses/compact') + || requestPath.endsWith('/v1/responses/compact') + ); + const isInvalidCompactUrl = compact.includes('invalid url') && hasRawCompactHint; if (status === 404 || status === 405 || status === 501) return true; return ( - compact.includes("unknown parameter: 'stream'") - || compact.includes('invalid url') + (compact.includes("unknown parameter: 'stream'") && (hasRawCompactHint || hasCompactRequestPathHint)) + || isInvalidCompactUrl || ( - hasCompactHint + hasRawCompactHint && ( compact.includes('not supported') || compact.includes('unsupported') diff --git a/src/server/proxy-core/cliProfiles/codexProfile.ts b/src/server/proxy-core/cliProfiles/codexProfile.ts index 09df8ba..33287ab 100644 --- a/src/server/proxy-core/cliProfiles/codexProfile.ts +++ b/src/server/proxy-core/cliProfiles/codexProfile.ts @@ -1,71 +1,14 @@ import type { CliProfileDefinition, DetectCliProfileInput } from './types.js'; +import { + detectCodexOfficialClientApp as detectCodexOfficialClientAppFromHeaders, + isCodexOfficialClientHeaders, +} from '../../shared/codexClientFamily.js'; type CodexOfficialClientApp = { clientAppId: string; clientAppName: string; }; -const CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES = [ - 'codex_cli_rs/', - 'codex_vscode/', - 'codex_app/', - 'codex_chatgpt_desktop/', - 'codex_atlas/', - 'codex_exec/', - 'codex_sdk_ts/', - 'codex ', -]; - -const CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES = [ - 'codex_', - 'codex ', -]; - -const CODEX_OFFICIAL_CLIENT_APP_RULES = [ - { - id: 'codex_cli_rs', - name: 'Codex CLI', - userAgentPrefixes: ['codex_cli_rs/'], - originatorPrefixes: ['codex_cli_rs'], - }, - { - id: 'codex_vscode', - name: 'Codex VSCode', - userAgentPrefixes: ['codex_vscode/'], - originatorPrefixes: ['codex_vscode'], - }, - { - id: 'codex_app', - name: 'Codex App', - userAgentPrefixes: ['codex_app/'], - originatorPrefixes: ['codex_app'], - }, - { - id: 'codex_chatgpt_desktop', - name: 'Codex Desktop', - userAgentPrefixes: ['codex_chatgpt_desktop/', 'codex desktop/'], - originatorPrefixes: ['codex_chatgpt_desktop', 'codex desktop'], - }, - { - id: 'codex_atlas', - name: 'Codex Atlas', - userAgentPrefixes: ['codex_atlas/'], - originatorPrefixes: ['codex_atlas'], - }, - { - id: 'codex_exec', - name: 'Codex Exec', - userAgentPrefixes: ['codex_exec/'], - originatorPrefixes: ['codex_exec'], - }, - { - id: 'codex_sdk_ts', - name: 'Codex SDK TS', - userAgentPrefixes: ['codex_sdk_ts/'], - originatorPrefixes: ['codex_sdk_ts'], - }, -] as const; - function headerValueToStrings(value: unknown): string[] { if (typeof value === 'string') { const trimmed = value.trim(); @@ -114,20 +57,6 @@ function hasHeaderPrefix(headers: Record | undefined, prefix: s }); } -function matchesHeaderPrefixes(value: string | string[] | null, prefixes: readonly string[]): boolean { - const values = Array.isArray(value) - ? value.map((item) => item.trim().toLowerCase()).filter(Boolean) - : [value?.trim().toLowerCase() || ''].filter(Boolean); - if (values.length === 0) return false; - - return values.some((normalizedValue) => prefixes.some((prefix) => { - const normalizedPrefix = prefix.trim().toLowerCase(); - if (!normalizedPrefix) return false; - return normalizedValue.startsWith(normalizedPrefix) - || normalizedValue.includes(normalizedPrefix); - })); -} - function isCodexPath(path: string): boolean { const normalizedPath = path.trim().toLowerCase(); return normalizedPath === '/v1/responses' @@ -138,16 +67,13 @@ function isCodexPath(path: string): boolean { export function detectCodexOfficialClientApp( headers?: Record, ): CodexOfficialClientApp | null { - for (const rule of CODEX_OFFICIAL_CLIENT_APP_RULES) { - const matchesOriginator = matchesHeaderPrefixes(getHeaderValues(headers, 'originator'), rule.originatorPrefixes); - const matchesUserAgent = matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), rule.userAgentPrefixes); - if (!matchesOriginator && !matchesUserAgent) continue; - return { - clientAppId: rule.id, - clientAppName: rule.name, - }; - } - return null; + const detected = detectCodexOfficialClientAppFromHeaders(headers); + return detected + ? { + clientAppId: detected.clientAppId, + clientAppName: detected.clientAppName, + } + : null; } export function isCodexResponsesSurface(headers?: Record): boolean { @@ -158,7 +84,10 @@ export function isCodexResponsesSurface(headers?: Record): bool } export function getCodexSessionId(headers?: Record): string | null { - return getHeaderValue(headers, 'session_id') || getHeaderValue(headers, 'session-id'); + return getHeaderValue(headers, 'session_id') + || getHeaderValue(headers, 'session-id') + || getHeaderValue(headers, 'conversation_id') + || getHeaderValue(headers, 'conversation-id'); } export function isCodexRequest(input: DetectCliProfileInput): boolean { @@ -166,9 +95,7 @@ export function isCodexRequest(input: DetectCliProfileInput): boolean { const headers = input.headers; if (!headers) return false; - const originator = getHeaderValues(headers, 'originator'); - if (matchesHeaderPrefixes(originator, CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES)) return true; - if (matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true; + if (isCodexOfficialClientHeaders(headers)) return true; if (getHeaderValue(headers, 'openai-beta')) return true; if (hasHeaderPrefix(headers, 'x-stainless-')) return true; if (getCodexSessionId(headers)) return true; diff --git a/src/server/proxy-core/cliProfiles/registry.test.ts b/src/server/proxy-core/cliProfiles/registry.test.ts index 809d3aa..bd97a52 100644 --- a/src/server/proxy-core/cliProfiles/registry.test.ts +++ b/src/server/proxy-core/cliProfiles/registry.test.ts @@ -42,6 +42,29 @@ describe('detectCliProfile', () => { }); }); + it('treats conversation_id as a Codex continuation marker when session_id is absent', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + conversation_id: 'codex-conversation-123', + }, + })).toEqual({ + id: 'codex', + sessionId: 'codex-conversation-123', + traceHint: 'codex-conversation-123', + clientAppId: 'codex', + clientAppName: 'Codex', + clientConfidence: 'heuristic', + capabilities: { + supportsResponsesCompact: true, + supportsResponsesWebsocketIncremental: true, + preservesContinuation: true, + supportsCountTokens: false, + echoesTurnState: true, + }, + }); + }); + it('detects broader Codex official-client headers from user-agent and originator prefixes', () => { expect(detectCliProfile({ downstreamPath: '/v1/responses', diff --git a/src/server/proxy-core/providers/claudeProviderProfile.ts b/src/server/proxy-core/providers/claudeProviderProfile.ts index 946dcfd..29068f7 100644 --- a/src/server/proxy-core/providers/claudeProviderProfile.ts +++ b/src/server/proxy-core/providers/claudeProviderProfile.ts @@ -1,5 +1,10 @@ import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js'; -import { buildClaudeRuntimeHeaders } from './headerUtils.js'; +import { + buildClaudeRuntimeHeaders, + CLAUDE_API_KEY_DEFAULT_BETA_HEADER, + CLAUDE_DEFAULT_BETA_HEADER, + CLAUDE_TOKEN_COUNTING_BETA, +} from './headerUtils.js'; export const claudeProviderProfile: ProviderProfile = { id: 'claude', @@ -22,6 +27,10 @@ export const claudeProviderProfile: ProviderProfile = { stream: isCountTokens ? false : input.stream, isClaudeOauthUpstream, tokenValue: input.tokenValue, + defaultBetaHeader: isClaudeOauthUpstream + ? CLAUDE_DEFAULT_BETA_HEADER + : CLAUDE_API_KEY_DEFAULT_BETA_HEADER, + ...(isCountTokens ? { extraBetas: [CLAUDE_TOKEN_COUNTING_BETA] } : {}), }), body: input.body, runtime: { diff --git a/src/server/proxy-core/providers/headerUtils.test.ts b/src/server/proxy-core/providers/headerUtils.test.ts index a18f60a..6bf4f1b 100644 --- a/src/server/proxy-core/providers/headerUtils.test.ts +++ b/src/server/proxy-core/providers/headerUtils.test.ts @@ -95,6 +95,7 @@ describe('provider header utils', () => { expect(headers['anthropic-version']).toBe('2023-06-01'); expect(headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(headers['anthropic-beta']).toContain('fine-grained-tool-streaming-2025-05-14'); expect(headers['anthropic-beta']).toContain('custom-beta'); expect(headers.Authorization).toBe('Bearer oauth-token'); expect(headers.authorization).toBeUndefined(); diff --git a/src/server/proxy-core/providers/headerUtils.ts b/src/server/proxy-core/providers/headerUtils.ts index f37e9a3..6a72abe 100644 --- a/src/server/proxy-core/providers/headerUtils.ts +++ b/src/server/proxy-core/providers/headerUtils.ts @@ -1,9 +1,12 @@ import { pbkdf2Sync, randomUUID } from 'node:crypto'; +import { inferCodexOfficialOriginator } from '../../shared/codexClientFamily.js'; const CODEX_CLIENT_VERSION = '0.101.0'; const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)'; -const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; +export const CLAUDE_TOKEN_COUNTING_BETA = 'token-counting-2024-11-01'; +export const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; +export const CLAUDE_API_KEY_DEFAULT_BETA_HEADER = 'claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; export function headerValueToString(value: unknown): string | null { if (typeof value === 'string') { @@ -132,7 +135,9 @@ export function buildCodexRuntimeHeaders(input: { || getInputHeader(input.baseHeaders, 'Authorization') || '' ); - const originator = getInputHeader(input.providerHeaders, 'originator') + const originator = inferCodexOfficialOriginator(input.providerHeaders) + || inferCodexOfficialOriginator(input.baseHeaders) + || getInputHeader(input.providerHeaders, 'originator') || input.originatorDefault || 'codex_cli_rs'; const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id'); diff --git a/src/server/proxy-core/providers/registry.test.ts b/src/server/proxy-core/providers/registry.test.ts index 87e2fd5..6b7fd8f 100644 --- a/src/server/proxy-core/providers/registry.test.ts +++ b/src/server/proxy-core/providers/registry.test.ts @@ -17,7 +17,7 @@ describe('resolveProviderProfile', () => { Authorization: 'Bearer oauth-access-token', }, providerHeaders: { - Originator: 'codex_cli_rs', + Originator: 'Codex Desktop', 'Chatgpt-Account-Id': 'chatgpt-account-123', }, codexSessionCacheKey: 'gpt-5.2-codex:user-123', @@ -30,7 +30,7 @@ describe('resolveProviderProfile', () => { expect(result.path).toBe('/responses'); expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); - expect(result.headers.Originator).toBe('codex_cli_rs'); + expect(result.headers.Originator).toBe('codex_chatgpt_desktop'); expect(result.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123'); expect(result.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); expect(result.headers.Conversation_id).toBe(result.headers.Session_id); @@ -74,6 +74,7 @@ describe('resolveProviderProfile', () => { expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); expect(result.headers['x-api-key']).toBeUndefined(); expect(result.headers['anthropic-version']).toBe('2023-06-01'); + expect(result.headers['anthropic-beta']).toContain('oauth-2025-04-20'); expect(result.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); expect(result.runtime).toMatchObject({ executor: 'claude', @@ -83,6 +84,60 @@ describe('resolveProviderProfile', () => { expect(result.body).toBe(protocolBody); }); + it('drops oauth-only claude betas for api-key upstreams', () => { + const profile = resolveProviderProfile('claude'); + expect(profile?.id).toBe('claude'); + + const result = profile!.prepareRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: false, + tokenValue: 'sk-claude-api-key', + sitePlatform: 'openai', + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: {}, + body: { + model: 'claude-opus-4-6', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(result.headers.Authorization).toBeUndefined(); + expect(result.headers['x-api-key']).toBe('sk-claude-api-key'); + expect(result.headers['anthropic-beta']).not.toContain('oauth-2025-04-20'); + expect(result.headers['anthropic-beta']).toContain('fine-grained-tool-streaming-2025-05-14'); + }); + + it('adds token-counting beta when building claude count_tokens requests', () => { + const profile = resolveProviderProfile('claude'); + expect(profile?.id).toBe('claude'); + + const result = profile!.prepareRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: false, + action: 'countTokens', + tokenValue: 'oauth-access-token', + oauthProvider: 'claude', + sitePlatform: 'claude', + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: {}, + body: { + model: 'claude-opus-4-6', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(result.path).toBe('/v1/messages/count_tokens?beta=true'); + expect(result.headers['anthropic-beta']).toContain('token-counting-2024-11-01'); + }); + it('builds gemini-cli provider requests with wrapped runtime envelope and project validation', () => { const profile = resolveProviderProfile('gemini-cli'); expect(profile?.id).toBe('gemini-cli'); diff --git a/src/server/proxy-core/runtime/codexSessionResponseStore.test.ts b/src/server/proxy-core/runtime/codexSessionResponseStore.test.ts index ba25742..2974759 100644 --- a/src/server/proxy-core/runtime/codexSessionResponseStore.test.ts +++ b/src/server/proxy-core/runtime/codexSessionResponseStore.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { config } from '../../config.js'; import { buildCodexSessionResponseStoreKey, clearCodexSessionResponseId, @@ -7,6 +8,10 @@ import { setCodexSessionResponseId, } from './codexSessionResponseStore.js'; +afterEach(() => { + vi.useRealTimers(); +}); + describe('codexSessionResponseStore', () => { it('evicts the oldest session id when the store exceeds the cap', () => { resetCodexSessionResponseStore(); @@ -154,4 +159,39 @@ describe('codexSessionResponseStore', () => { resetCodexSessionResponseStore(); }); + + it('expires remembered continuation ids after the sticky-session TTL window', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-09T00:00:00Z')); + resetCodexSessionResponseStore(); + + setCodexSessionResponseId('session-expire', 'resp-expire'); + expect(getCodexSessionResponseId('session-expire')).toBe('resp-expire'); + + vi.advanceTimersByTime(Math.max(5 * 60 * 1000, Math.trunc(config.proxyStickySessionTtlMs || 0)) + 1); + + expect(getCodexSessionResponseId('session-expire')).toBeNull(); + + resetCodexSessionResponseStore(); + }); + + it('refreshes the remembered continuation TTL when an active session reads it', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-09T00:00:00Z')); + resetCodexSessionResponseStore(); + + const ttlMs = Math.max(5 * 60 * 1000, Math.trunc(config.proxyStickySessionTtlMs || 0)); + setCodexSessionResponseId('session-touch', 'resp-touch'); + + vi.advanceTimersByTime(ttlMs - 1_000); + expect(getCodexSessionResponseId('session-touch')).toBe('resp-touch'); + + vi.advanceTimersByTime(2_000); + expect(getCodexSessionResponseId('session-touch')).toBe('resp-touch'); + + vi.advanceTimersByTime(ttlMs + 1); + expect(getCodexSessionResponseId('session-touch')).toBeNull(); + + resetCodexSessionResponseStore(); + }); }); diff --git a/src/server/proxy-core/runtime/codexSessionResponseStore.ts b/src/server/proxy-core/runtime/codexSessionResponseStore.ts index 487b6f8..9e9223b 100644 --- a/src/server/proxy-core/runtime/codexSessionResponseStore.ts +++ b/src/server/proxy-core/runtime/codexSessionResponseStore.ts @@ -1,6 +1,14 @@ -const MAX_CODEX_SESSION_RESPONSE_IDS = 10_000; +import { config } from '../../config.js'; -const codexSessionResponseIds = new Map(); +const MAX_CODEX_SESSION_RESPONSE_IDS = 10_000; +const MIN_CODEX_SESSION_RESPONSE_TTL_MS = 5 * 60 * 1000; + +type SessionResponseEntry = { + responseId: string; + updatedAtMs: number; +}; + +const codexSessionResponseIds = new Map(); const SCOPED_SESSION_SEGMENT_PREFIX = 'session:'; const SCOPED_STORE_KEY_SEGMENT_PATTERN = /^(site|account|channel):\d+$/; @@ -71,6 +79,33 @@ function normalizeSessionId(sessionId: string): string { return sessionId.trim(); } +function getCodexSessionResponseTtlMs(): number { + return Math.max( + MIN_CODEX_SESSION_RESPONSE_TTL_MS, + Math.trunc(config.proxyStickySessionTtlMs || 0), + ); +} + +function isExpiredSessionResponseEntry(entry: SessionResponseEntry, nowMs = Date.now()): boolean { + return (entry.updatedAtMs + getCodexSessionResponseTtlMs()) <= nowMs; +} + +function sweepExpiredSessionResponseIds(nowMs = Date.now()): void { + for (const [key, entry] of codexSessionResponseIds.entries()) { + if (!isExpiredSessionResponseEntry(entry, nowMs)) continue; + codexSessionResponseIds.delete(key); + } +} + +function touchSessionResponseEntry(sessionId: string, nowMs = Date.now()): void { + for (const key of getSessionStoreKeys(sessionId)) { + const entry = codexSessionResponseIds.get(key); + if (!entry) continue; + entry.updatedAtMs = nowMs; + codexSessionResponseIds.set(key, entry); + } +} + export function buildCodexSessionResponseStoreKey(input: { sessionId: string; siteId?: number | null; @@ -89,17 +124,23 @@ export function buildCodexSessionResponseStoreKey(input: { } export function getCodexSessionResponseId(sessionId: string): string | null { + const nowMs = Date.now(); + sweepExpiredSessionResponseIds(nowMs); const normalized = normalizeSessionId(sessionId); if (!normalized) return null; const direct = codexSessionResponseIds.get(normalized); - if (direct) return direct; + if (direct) { + touchSessionResponseEntry(normalized, nowMs); + return direct.responseId; + } for (const fallbackKey of getFallbackSessionStoreKeys(normalized)) { const fallback = codexSessionResponseIds.get(fallbackKey); if (fallback) { reconcileScopedSessionFallback(fallbackKey); - return fallback; + touchSessionResponseEntry(fallbackKey, nowMs); + return fallback.responseId; } } @@ -107,6 +148,8 @@ export function getCodexSessionResponseId(sessionId: string): string | null { } export function setCodexSessionResponseId(sessionId: string, responseId: string): void { + const nowMs = Date.now(); + sweepExpiredSessionResponseIds(nowMs); const normalizedSessionId = normalizeSessionId(sessionId); const normalizedResponseId = responseId.trim(); if (!normalizedSessionId || !normalizedResponseId) return; @@ -117,7 +160,10 @@ export function setCodexSessionResponseId(sessionId: string, responseId: string) if (codexSessionResponseIds.has(key)) { codexSessionResponseIds.delete(key); } - codexSessionResponseIds.set(key, normalizedResponseId); + codexSessionResponseIds.set(key, { + responseId: normalizedResponseId, + updatedAtMs: nowMs, + }); } while (codexSessionResponseIds.size > MAX_CODEX_SESSION_RESPONSE_IDS) { diff --git a/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts b/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts index 26a9569..ed0e6df 100644 --- a/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts +++ b/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts @@ -141,6 +141,67 @@ describe('codexWebsocketRuntime', () => { await runtime.closeSession('exec-session-close'); }); + it('preserves remembered continuation ids across websocket session closes and reconnects', async () => { + const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js'); + const runtime = createCodexWebsocketRuntime(); + + await runtime.sendRequest({ + sessionId: 'exec-session-continue-after-close', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [], + }, + }); + + await runtime.closeSession('exec-session-continue-after-close'); + + const recovered = await runtime.sendRequest({ + sessionId: 'exec-session-continue-after-close', + requestUrl: upstreamWsUrl, + headers: { + Authorization: 'Bearer oauth-access-token', + 'OpenAI-Beta': 'responses_websockets=2026-02-06', + }, + body: { + model: 'gpt-5.4', + input: [ + { + id: 'tool_out_runtime_1', + type: 'function_call_output', + call_id: 'call_runtime_1', + output: '{"ok":true}', + }, + ], + }, + }); + + expect(recovered.events[0]).toMatchObject({ + type: 'response.completed', + response: { id: 'resp-2' }, + }); + expect(upstreamConnectionCount).toBe(2); + expect(upstreamRequests).toHaveLength(2); + expect(upstreamRequests[1]).toMatchObject({ + type: 'response.create', + previous_response_id: 'resp-1', + input: [ + { + id: 'tool_out_runtime_1', + type: 'function_call_output', + call_id: 'call_runtime_1', + output: '{"ok":true}', + }, + ], + }); + + await runtime.closeSession('exec-session-continue-after-close'); + }); + it('returns response.incomplete as a terminal websocket event without rejecting the session turn', async () => { upstreamMessageHandler = (socket, parsed) => { socket.send(JSON.stringify({ @@ -413,7 +474,8 @@ describe('codexWebsocketRuntime', () => { message: 'account mismatch', status: 502, }); - expect((error as CodexWebsocketRuntimeError).events).toEqual([ + const runtimeError = error as InstanceType; + expect(runtimeError.events).toEqual([ expect.objectContaining({ type: 'error', error: expect.objectContaining({ diff --git a/src/server/proxy-core/runtime/codexWebsocketRuntime.ts b/src/server/proxy-core/runtime/codexWebsocketRuntime.ts index b861b4c..5708de1 100644 --- a/src/server/proxy-core/runtime/codexWebsocketRuntime.ts +++ b/src/server/proxy-core/runtime/codexWebsocketRuntime.ts @@ -337,7 +337,7 @@ async function sendSessionRequestAttempt( socket.once('error', onError); socket.once('close', onClose); - socket.send(JSON.stringify(buildCodexWebsocketRequestBody(input.body)), (error) => { + socket.send(JSON.stringify(buildCodexWebsocketRequestBody(input.body)), (error?: Error) => { if (!error) return; clearSessionSocket(session, socket); rejectWith(error.message || 'failed to send upstream websocket request'); @@ -409,7 +409,9 @@ export function createCodexWebsocketRuntime(input?: { await closeSocket(session.socket); session.socket = null; session.socketUrl = null; - clearCodexSessionResponseId(sessionId); + // Intentionally preserve the remembered previous_response_id across + // websocket reconnects. Closing a transport session should not sever the + // logical downstream continuation chain for the same session key. }, async closeAllSessions(): Promise { diff --git a/src/server/routes/proxy/responsesSseFinal.test.ts b/src/server/proxy-core/runtime/responsesSseFinal.test.ts similarity index 100% rename from src/server/routes/proxy/responsesSseFinal.test.ts rename to src/server/proxy-core/runtime/responsesSseFinal.test.ts diff --git a/src/server/routes/proxy/responsesSseFinal.ts b/src/server/proxy-core/runtime/responsesSseFinal.ts similarity index 99% rename from src/server/routes/proxy/responsesSseFinal.ts rename to src/server/proxy-core/runtime/responsesSseFinal.ts index d46466f..72989c1 100644 --- a/src/server/routes/proxy/responsesSseFinal.ts +++ b/src/server/proxy-core/runtime/responsesSseFinal.ts @@ -1,6 +1,6 @@ import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { readRuntimeResponseText } from '../../proxy-core/executors/types.js'; +import { readRuntimeResponseText } from '../executors/types.js'; type ResponsesTerminalStatus = 'completed' | 'incomplete'; diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 49a32db..9214afb 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -5,6 +5,8 @@ import { tokenRouter } from '../../services/tokenRouter.js'; import { reportProxyAllFailed } from '../../services/alertService.js'; import { hasProxyUsagePayload, mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; +import { promoteRequiredEndpointCandidateAfterProtocolError } from '../../transformers/shared/endpointCompatibility.js'; +import { shouldForceResponsesUpstreamStream } from '../capabilities/responsesCompact.js'; import { buildClaudeCountTokensUpstreamRequest, buildUpstreamEndpointRequest, @@ -21,7 +23,7 @@ import { recordDownstreamCostUsage, } from '../../routes/proxy/downstreamPolicy.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../orchestration/endpointFlow.js'; -import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { detectProxyFailure } from '../../services/proxyFailureJudge.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; import { shouldPreferResponsesForAnthropicContinuation } from '../../transformers/anthropic/messages/compatibility.js'; @@ -39,11 +41,11 @@ import { collectResponsesFinalPayloadFromSseText, createSingleChunkStreamReader, looksLikeResponsesSseText, -} from '../../routes/proxy/responsesSseFinal.js'; +} from '../runtime/responsesSseFinal.js'; import { createGeminiCliStreamReader, unwrapGeminiCliPayload, -} from '../../routes/proxy/geminiCliCompat.js'; +} from '../../transformers/gemini/generate-content/cliBridge.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; import { getObservedResponseMeta } from '../firstByteTimeout.js'; import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js'; @@ -90,17 +92,6 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function prioritizeEndpointCandidate( - candidates: Array<'chat' | 'messages' | 'responses'>, - preferred: 'chat' | 'messages' | 'responses', -): Array<'chat' | 'messages' | 'responses'> { - if (!candidates.includes(preferred)) return candidates; - return [ - preferred, - ...candidates.filter((candidate) => candidate !== preferred), - ]; -} - function finalizeRetryAsUpstreamFailure(status: number, message: string) { return { action: 'respond' as const, @@ -292,11 +283,11 @@ export async function handleChatSurfaceRequest( conversationFileSummary, wantsContinuationAwareResponses, }, + { + oauthProvider: oauth?.provider, + }, ), ]; - if (oauth?.provider === 'codex' && downstreamFormat === 'openai') { - endpointCandidates = prioritizeEndpointCandidate(endpointCandidates, 'responses'); - } const endpointRuntimeContext = { siteId: selected.site.id, modelName, @@ -328,11 +319,15 @@ export async function handleChatSurfaceRequest( }) ); const executeEndpointResultForSiteApiBaseUrl = async (siteApiBaseUrl: string) => { + const forceResponsesUpstreamStream = shouldForceResponsesUpstreamStream({ + sitePlatform: selected.site.platform, + isCompactRequest: false, + }); const buildEndpointRequest = ( endpoint: 'chat' | 'messages' | 'responses', options: { forceNormalizeClaudeBody?: boolean } = {}, ) => { - const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); + const upstreamStream = isStream || (forceResponsesUpstreamStream && endpoint === 'responses'); const endpointRequest = buildUpstreamEndpointRequest({ endpoint, modelName, @@ -369,7 +364,7 @@ export async function handleChatSurfaceRequest( modelName, requestedModelHint: requestedModel, sitePlatform: selected.site.platform, - isStream: isStream || isCodexSite, + isStream: isStream || forceResponsesUpstreamStream, buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( endpoint, { forceNormalizeClaudeBody }, @@ -455,6 +450,10 @@ export async function handleChatSurfaceRequest( }, shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: async (ctx) => { + promoteRequiredEndpointCandidateAfterProtocolError(endpointCandidates, { + currentEndpoint: ctx.request.endpoint, + upstreamErrorText: ctx.rawErrText, + }); await safeUpdateSurfaceProxyDebugAttempt(debugTrace, debugAttemptBase + ctx.endpointIndex, { downgradeDecision: true, downgradeReason: ctx.errText, @@ -1183,6 +1182,10 @@ export async function handleClaudeCountTokensSurfaceRequest( modelName, 'claude', requestedModel, + undefined, + { + requestKind: 'claude-count-tokens', + }, ); await safeUpdateSurfaceProxyDebugCandidates(debugTrace, { endpointCandidates, @@ -1194,7 +1197,7 @@ export async function handleClaudeCountTokensSurfaceRequest( countTokens: true, }, }); - if (!endpointCandidates.includes('messages')) { + if (endpointCandidates.length === 0) { if (canRetryChannelSelection(retryCount, forcedChannelId)) { retryCount += 1; continue; diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index cdd1de5..68d0717 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -15,7 +15,7 @@ import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../se import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../orchestration/endpointFlow.js'; -import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; +import { composeProxyLogMessage } from '../../services/proxyLogMessage.js'; import { buildUpstreamEndpointRequest, resolveUpstreamEndpointCandidates, @@ -35,7 +35,7 @@ import { createGeminiCliStreamReader, unwrapGeminiCliPayload, wrapGeminiCliRequest, -} from '../../routes/proxy/geminiCliCompat.js'; +} from '../../transformers/gemini/generate-content/cliBridge.js'; import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from '../downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 67087cc..c45ea85 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -23,9 +23,10 @@ import { } from '../../services/upstreamEndpointRuntimeMemory.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../orchestration/endpointFlow.js'; -import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { detectProxyFailure } from '../../services/proxyFailureJudge.js'; import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; +import { promoteRequiredEndpointCandidateAfterProtocolError } from '../../transformers/shared/endpointCompatibility.js'; import { ProxyInputFileResolutionError, resolveResponsesBodyInputFiles, @@ -39,11 +40,11 @@ import { collectResponsesFinalPayloadFromSseText, createSingleChunkStreamReader, looksLikeResponsesSseText, -} from '../../routes/proxy/responsesSseFinal.js'; +} from '../runtime/responsesSseFinal.js'; import { createGeminiCliStreamReader, unwrapGeminiCliPayload, -} from '../../routes/proxy/geminiCliCompat.js'; +} from '../../transformers/gemini/generate-content/cliBridge.js'; import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js'; import { getObservedResponseMeta } from '../firstByteTimeout.js'; import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js'; @@ -59,7 +60,9 @@ import { summarizeConversationFileInputsInResponsesBody, } from '../capabilities/conversationFileCapabilities.js'; import { + ensureCompactResponsesJsonAcceptHeader, sanitizeCompactResponsesRequestBody, + shouldForceResponsesUpstreamStream, shouldFallbackCompactResponsesToResponses, } from '../capabilities/responsesCompact.js'; import { detectDownstreamClientContext } from '../downstreamClientContext.js'; @@ -104,7 +107,12 @@ function isRecord(value: unknown): value is Record { function getCodexSessionHeaderValue(headers: Record): string { for (const [rawKey, rawValue] of Object.entries(headers)) { const normalizedKey = rawKey.trim().toLowerCase(); - if (normalizedKey === 'session_id' || normalizedKey === 'session-id') { + if ( + normalizedKey === 'session_id' + || normalizedKey === 'session-id' + || normalizedKey === 'conversation_id' + || normalizedKey === 'conversation-id' + ) { return String(rawValue || '').trim(); } } @@ -417,7 +425,24 @@ export async function handleOpenAiResponsesSurfaceRequest( const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl || carriesResponsesFileUrlInput(normalizedResponsesBody.input); const endpointCandidates: UpstreamEndpoint[] = isCompactRequest - ? ['responses'] + ? await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + modelName, + 'responses', + requestedModel, + { + hasNonImageFileInput, + conversationFileSummary, + wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, + }, + { + requestKind: 'responses-compact', + requiresNativeResponsesFileUrl, + }, + ) : await resolveUpstreamEndpointCandidates( { site: selected.site, @@ -431,10 +456,10 @@ export async function handleOpenAiResponsesSurfaceRequest( conversationFileSummary, wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, }, + { + requiresNativeResponsesFileUrl, + }, ); - if (endpointCandidates.length === 0) { - endpointCandidates.push('responses', 'chat', 'messages'); - } const endpointRuntimeContext = { siteId: selected.site.id, modelName, @@ -467,9 +492,12 @@ export async function handleOpenAiResponsesSurfaceRequest( }) ); const executeEndpointResultForSiteApiBaseUrl = async (siteApiBaseUrl: string) => { - const forceCodexUpstreamStream = isCodexSite && !isCompactRequest; + const forceResponsesUpstreamStream = shouldForceResponsesUpstreamStream({ + sitePlatform: selected.site.platform, + isCompactRequest, + }); const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => { - const upstreamStream = isStream || (forceCodexUpstreamStream && endpoint === 'responses'); + const upstreamStream = isStream || (forceResponsesUpstreamStream && endpoint === 'responses'); const responsesOriginalBody = ( endpoint === 'responses' && isCodexSite @@ -498,6 +526,7 @@ export async function handleOpenAiResponsesSurfaceRequest( responsesOriginalBody, downstreamHeaders: request.headers as Record, providerHeaders: buildProviderHeaders(), + codexExplicitSessionId: codexSessionId || null, }); const upstreamPath = ( isCompactRequest && endpoint === 'responses' @@ -506,13 +535,22 @@ export async function handleOpenAiResponsesSurfaceRequest( ); const requestBody = ( isCompactRequest && endpoint === 'responses' - ? sanitizeCompactResponsesRequestBody(endpointRequest.body as Record) + ? sanitizeCompactResponsesRequestBody(endpointRequest.body as Record, { + sitePlatform: selected.site.platform, + }) : endpointRequest.body as Record ); + const requestHeaders = ( + isCompactRequest && endpoint === 'responses' + ? ensureCompactResponsesJsonAcceptHeader(endpointRequest.headers, { + sitePlatform: selected.site.platform, + }) + : endpointRequest.headers + ); return { endpoint, path: upstreamPath, - headers: endpointRequest.headers, + headers: requestHeaders, body: requestBody, runtime: endpointRequest.runtime, }; @@ -536,8 +574,9 @@ export async function handleOpenAiResponsesSurfaceRequest( ); }; const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({ - isStream: isStream || forceCodexUpstreamStream, + isStream: isStream || forceResponsesUpstreamStream, requiresNativeResponsesFileUrl, + sitePlatform: selected.site.platform, dispatchRequest, }); const tryRecover = async (ctx: Parameters>[0]) => { @@ -587,19 +626,44 @@ export async function handleOpenAiResponsesSurfaceRequest( ctx.rawErrText = await readRuntimeResponseText(recoveredResponse).catch(() => 'unknown error'); } } + const compactFallbackEnabled = config.responsesCompactFallbackToResponsesEnabled || !isCodexSite; if ( isCompactRequest - && config.responsesCompactFallbackToResponsesEnabled + && compactFallbackEnabled && ctx.request.endpoint === 'responses' && ctx.request.path.endsWith('/responses/compact') && shouldFallbackCompactResponsesToResponses({ status: ctx.response.status, rawErrText: ctx.rawErrText, + requestPath: ctx.request.path, }) ) { + const normalizedSitePlatform = String(selected.site.platform || '').trim().toLowerCase(); + const recoveredUpstreamStream = shouldForceResponsesUpstreamStream({ + sitePlatform: selected.site.platform, + isCompactRequest: false, + }); + const recoveredHeaders = { ...ctx.request.headers } as Record; + delete (recoveredHeaders as Record).Accept; + if (recoveredUpstreamStream) { + recoveredHeaders.accept = 'text/event-stream'; + } + const recoveredBody = isRecord(ctx.request.body) + ? { ...ctx.request.body } + : ctx.request.body; + if (isRecord(recoveredBody)) { + if (recoveredUpstreamStream) { + recoveredBody.stream = true; + } + if (normalizedSitePlatform === 'codex' || normalizedSitePlatform === 'sub2api') { + recoveredBody.store = false; + } + } const recoveredRequest = { ...ctx.request, path: ctx.request.path.replace(/\/compact$/, ''), + headers: recoveredHeaders, + body: recoveredBody, }; const recoveredResponse = await dispatchRequest(recoveredRequest); if (recoveredResponse.ok) { @@ -684,6 +748,10 @@ export async function handleOpenAiResponsesSurfaceRequest( }, shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: async (ctx) => { + promoteRequiredEndpointCandidateAfterProtocolError(endpointCandidates, { + currentEndpoint: ctx.request.endpoint, + upstreamErrorText: ctx.rawErrText, + }); await safeUpdateSurfaceProxyDebugAttempt(debugTrace, debugAttemptBase + ctx.endpointIndex, { downgradeDecision: true, downgradeReason: ctx.errText, diff --git a/src/server/proxy-core/surfaces/sharedSurface.test.ts b/src/server/proxy-core/surfaces/sharedSurface.test.ts index b2c2ba3..8c32ebd 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.test.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.test.ts @@ -50,11 +50,11 @@ vi.mock('../../services/proxyChannelCoordinator.js', () => ({ }, })); -vi.mock('../../services/modelService.js', () => ({ +vi.mock('../../services/routeRefreshWorkflow.js', () => ({ refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), })); -vi.mock('../../routes/proxy/logPathMeta.js', () => ({ +vi.mock('../../services/proxyLogMessage.js', () => ({ composeProxyLogMessage: (...args: unknown[]) => composeProxyLogMessageMock(...args), })); @@ -71,7 +71,7 @@ vi.mock('../../services/siteProxy.js', () => ({ withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args), })); -vi.mock('../../routes/proxy/runtimeExecutor.js', () => ({ +vi.mock('../../services/runtimeDispatch.js', () => ({ dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args), })); @@ -98,7 +98,7 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({ resolveProxyUsageWithSelfLogFallback: (...args: unknown[]) => resolveProxyUsageWithSelfLogFallbackMock(...args), })); -vi.mock('../../routes/proxy/proxyBilling.js', () => ({ +vi.mock('../../services/proxyBilling.js', () => ({ resolveProxyLogBilling: (...args: unknown[]) => resolveProxyLogBillingMock(...args), })); diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index 583067b..55a4a24 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -7,8 +7,8 @@ import type { DownstreamRoutingPolicy } from '../../services/downstreamPolicyTyp import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; -import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; +import { composeProxyLogMessage } from '../../services/proxyLogMessage.js'; +import { resolveProxyLogBilling } from '../../services/proxyBilling.js'; import type { DownstreamClientContext } from '../downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js'; diff --git a/src/server/proxy-core/surfaces/sharedSurface.usage-source.test.ts b/src/server/proxy-core/surfaces/sharedSurface.usage-source.test.ts index 2fecaba..b4b59a2 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.usage-source.test.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.usage-source.test.ts @@ -37,11 +37,11 @@ vi.mock('../../services/proxyRetryPolicy.js', () => ({ shouldRetryProxyRequest: vi.fn(() => false), })); -vi.mock('../../routes/proxy/logPathMeta.js', () => ({ +vi.mock('../../services/proxyLogMessage.js', () => ({ composeProxyLogMessage: (...args: unknown[]) => composeProxyLogMessageMock(...args), })); -vi.mock('../../routes/proxy/proxyBilling.js', () => ({ +vi.mock('../../services/proxyBilling.js', () => ({ resolveProxyLogBilling: vi.fn(), })); diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index e853f21..db4fb3c 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -68,6 +68,13 @@ describe('settings and auth events', () => { (config as any).proxyFirstByteTimeoutSec = 0; (config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60; (config as any).disableCrossProtocolFallback = false; + (config as any).payloadRules = { + default: [], + defaultRaw: [], + override: [], + overrideRaw: [], + filter: [], + }; (config as any).telegramEnabled = false; (config as any).telegramApiBaseUrl = 'https://api.telegram.org'; (config as any).telegramBotToken = ''; @@ -219,6 +226,101 @@ describe('settings and auth events', () => { expect(savedMaxBodyBytes?.value).toBe(JSON.stringify(131072)); }); + it('persists payload rules and returns the normalized runtime value', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + payloadRules: { + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + 'override-raw': [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{"type":"json_schema"}', + }, + }, + ], + }, + }, + }); + + expect(response.statusCode).toBe(200); + const payload = response.json() as { + payloadRules?: { + default?: unknown[]; + defaultRaw?: unknown[]; + override?: Array<{ params?: Record }>; + overrideRaw?: Array<{ params?: Record }>; + filter?: unknown[]; + }; + }; + expect(payload.payloadRules).toEqual({ + default: [], + defaultRaw: [], + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + overrideRaw: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{"type":"json_schema"}', + }, + }, + ], + filter: [], + }); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get(); + expect(saved).toBeTruthy(); + expect(JSON.parse(String(saved?.value))).toEqual(payload.payloadRules); + expect(config.payloadRules).toEqual(payload.payloadRules); + + const events = await db.select().from(schema.events).all(); + expect(events.some((event) => (event.message || '').includes('Payload 规则'))).toBe(true); + }); + + it('rejects invalid payload raw rules with a clear message', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + payloadRules: { + 'override-raw': [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{broken-json', + }, + }, + ], + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Payload 规则 override-raw 第 1 条的 response_format 不是合法 JSON', + }); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get(); + expect(saved).toBeFalsy(); + }); + it('returns current recognized admin IP in runtime settings response', async () => { const response = await app.inject({ method: 'GET', diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index a6acc6c..31f625b 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -44,12 +44,14 @@ import { startModelAvailabilityProbeScheduler, stopModelAvailabilityProbeScheduler, } from '../../services/modelAvailabilityProbeService.js'; +import { parsePayloadRulesConfigInput } from '../../services/payloadRules.js'; type RoutingWeights = typeof config.routingWeights; interface RuntimeSettingsBody { proxyToken?: string; systemProxyUrl?: string; + payloadRules?: unknown; modelAvailabilityProbeEnabled?: boolean; codexUpstreamWebsocketEnabled?: boolean; responsesCompactFallbackToResponsesEnabled?: boolean; @@ -759,6 +761,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { currentAdminIp, serverTimeZone: getResolvedTimeZone(), systemProxyUrl: config.systemProxyUrl, + payloadRules: config.payloadRules, proxyErrorKeywords: config.proxyErrorKeywords, proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled, proxyTokenMasked: maskSecret(config.proxyToken), @@ -1132,6 +1135,24 @@ export async function settingsRoutes(app: FastifyInstance) { invalidateSiteProxyCache(); } + if (body.payloadRules !== undefined) { + const parsedPayloadRules = parsePayloadRulesConfigInput(body.payloadRules); + if (!parsedPayloadRules.success) { + return reply.code(400).send({ + success: false, + message: parsedPayloadRules.message, + }); + } + + const previousRules = JSON.stringify(config.payloadRules); + const nextRules = JSON.stringify(parsedPayloadRules.normalized); + if (previousRules !== nextRules) { + changedLabels.push('Payload 规则'); + } + config.payloadRules = parsedPayloadRules.normalized; + await upsertSetting('payload_rules', config.payloadRules); + } + if (body.modelAvailabilityProbeEnabled !== undefined) { let nextValue = false; try { diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 4325188..229690e 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -520,8 +520,30 @@ function mapProxyLogRow( }; } +function buildProxyLogModelAnalysisSelectFields() { + return { + createdAt: schema.proxyLogs.createdAt, + modelActual: schema.proxyLogs.modelActual, + modelRequested: schema.proxyLogs.modelRequested, + status: schema.proxyLogs.status, + latencyMs: schema.proxyLogs.latencyMs, + totalTokens: schema.proxyLogs.totalTokens, + estimatedCost: schema.proxyLogs.estimatedCost, + }; +} + +function buildProxyLogSiteTrendSelectFields() { + return { + createdAt: schema.proxyLogs.createdAt, + estimatedCost: schema.proxyLogs.estimatedCost, + totalTokens: schema.proxyLogs.totalTokens, + }; +} + export async function statsRoutes(app: FastifyInstance) { const proxyLogBaseFields = getProxyLogBaseSelectFields(); + const proxyLogModelAnalysisFields = buildProxyLogModelAnalysisSelectFields(); + const proxyLogSiteTrendFields = buildProxyLogSiteTrendSelectFields(); // Dashboard summary app.get('/api/stats/dashboard', async () => { @@ -565,9 +587,7 @@ export async function statsRoutes(app: FastifyInstance) { const lastMinuteDate = formatUtcSqlDateTime(new Date(nowTs - 60_000)); const last7dDate = getLocalRangeStartUtc(7); const recentProxyLogs = (await db.select({ - proxy_logs: proxyLogBaseFields, - accounts: schema.accounts, - sites: schema.sites, + proxy_logs: proxyLogModelAnalysisFields, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) @@ -720,8 +740,14 @@ export async function statsRoutes(app: FastifyInstance) { const listRows = await withProxyLogSelectFields(({ fields }) => { let query = db.select({ proxy_logs: fields, - accounts: schema.accounts, - sites: schema.sites, + accounts: { + username: schema.accounts.username, + }, + sites: { + id: schema.sites.id, + name: schema.sites.name, + url: schema.sites.url, + }, downstream_api_keys: { id: schema.downstreamApiKeys.id, name: schema.downstreamApiKeys.name, @@ -778,7 +804,9 @@ export async function statsRoutes(app: FastifyInstance) { query = query.where(clientOptionsWhere) as typeof query; } - return query.all(); + return query + .groupBy(fields.clientFamily!, fields.clientAppId!, fields.clientAppName!) + .all(); }, { includeBillingDetails: false, includeClientFields: true }) as Array<{ clientFamily?: string | null; clientAppId?: string | null; @@ -1616,9 +1644,11 @@ export async function statsRoutes(app: FastifyInstance) { const sinceDate = getLocalRangeStartUtc(days); const rows = await db.select({ - proxy_logs: proxyLogBaseFields, - accounts: schema.accounts, - sites: schema.sites, + proxy_logs: proxyLogSiteTrendFields, + sites: { + name: schema.sites.name, + platform: schema.sites.platform, + }, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) @@ -1676,9 +1706,16 @@ export async function statsRoutes(app: FastifyInstance) { } const rows = await db.select({ - proxy_logs: proxyLogBaseFields, - accounts: schema.accounts, - sites: schema.sites, + proxy_logs: { + accountId: schema.proxyLogs.accountId, + modelActual: schema.proxyLogs.modelActual, + modelRequested: schema.proxyLogs.modelRequested, + totalTokens: schema.proxyLogs.totalTokens, + estimatedCost: schema.proxyLogs.estimatedCost, + }, + sites: { + platform: schema.sites.platform, + }, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) diff --git a/src/server/routes/proxy/chat.codex-oauth.test.ts b/src/server/routes/proxy/chat.codex-oauth.test.ts index 4fc166e..3563eec 100644 --- a/src/server/routes/proxy/chat.codex-oauth.test.ts +++ b/src/server/routes/proxy/chat.codex-oauth.test.ts @@ -21,6 +21,8 @@ const dbInsertMock = vi.fn((_arg?: any) => ({ }), })); +const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; + vi.mock('undici', async () => { const actual = await vi.importActual('undici'); return { @@ -206,7 +208,7 @@ describe('chat proxy codex oauth compatibility', () => { const [, options] = fetchMock.mock.calls[0] as [string, any]; const forwardedBody = JSON.parse(options.body); expect(forwardedBody.stream).toBe(true); - expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS); expect(forwardedBody.store).toBe(false); expect(forwardedBody.parallel_tool_calls).toBeUndefined(); expect(forwardedBody.include).toBeUndefined(); diff --git a/src/server/routes/proxy/chat.count-tokens.test.ts b/src/server/routes/proxy/chat.count-tokens.test.ts index 181f810..4cfa820 100644 --- a/src/server/routes/proxy/chat.count-tokens.test.ts +++ b/src/server/routes/proxy/chat.count-tokens.test.ts @@ -213,6 +213,7 @@ describe('claude count_tokens proxy route', () => { expect(targetUrl).toBe('https://api.anthropic.com/v1/messages/count_tokens?beta=true'); expect(options.headers['anthropic-version']).toBe('2023-06-01'); expect(options.headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(options.headers['anthropic-beta']).toContain('token-counting-2024-11-01'); expect(options.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); expect(startSurfaceProxyDebugTraceMock).toHaveBeenCalledWith(expect.objectContaining({ downstreamPath: '/v1/messages/count_tokens', @@ -276,4 +277,38 @@ describe('claude count_tokens proxy route', () => { expect(options.headers['x-api-key']).toBe('sk-gateway'); expect(options.headers['anthropic-version']).toBe('2023-06-01'); }); + + it('does not forward when claude count_tokens upstream compatibility is unavailable', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 12, routeId: 23 }, + site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' }, + account: { id: 34, username: 'codex-user@example.com' }, + tokenName: 'default', + tokenValue: 'sk-codex', + actualModel: 'gpt-5.4', + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages/count_tokens', + payload: { + model: 'gpt-5.4', + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'count through codex' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ + error: { + message: 'No available channels for this model', + type: 'server_error', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/server/routes/proxy/chat.siteApiEndpoint.test.ts b/src/server/routes/proxy/chat.siteApiEndpoint.test.ts index 700b885..8f38072 100644 --- a/src/server/routes/proxy/chat.siteApiEndpoint.test.ts +++ b/src/server/routes/proxy/chat.siteApiEndpoint.test.ts @@ -222,10 +222,10 @@ describe('chat proxy site api endpoint rotation', () => { expect(response.statusCode).toBe(200); expect(response.json()?.choices?.[0]?.message?.content).toBe('ok via api-b'); expect(fetchMock).toHaveBeenCalledTimes(4); - expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-a.example.com/v1/chat/completions'); - expect(String(fetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-a.example.com/v1/responses'); + expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-a.example.com/v1/responses'); + expect(String(fetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-a.example.com/v1/chat/completions'); expect(String(fetchMock.mock.calls[2]?.[0] || '')).toBe('https://api-a.example.com/v1/messages'); - expect(String(fetchMock.mock.calls[3]?.[0] || '')).toBe('https://api-b.example.com/v1/chat/completions'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toBe('https://api-b.example.com/v1/responses'); expect(selectNextChannelMock).not.toHaveBeenCalled(); expect(recordFailureMock).not.toHaveBeenCalled(); expect(recordSuccessMock).toHaveBeenCalledTimes(1); diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index c8aad7b..63aa519 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -1389,6 +1389,17 @@ describe('chat proxy stream behavior', () => { const response = await app.inject({ method: 'POST', url: '/v1/responses', + headers: { + 'accept-language': 'zh-CN', + 'openai-beta': 'responses-2025-03-11', + 'x-stainless-lang': 'typescript', + originator: 'codex_cli_rs', + session_id: 'session-123', + conversation_id: 'conversation-123', + 'x-codex-turn-state': 'turn-state', + 'x-codex-turn-metadata': 'turn-metadata', + version: '0.202.0', + }, payload: { model: 'gpt-5.2', input: 'hello', @@ -1414,6 +1425,55 @@ describe('chat proxy stream behavior', () => { ]); }); + it('forces upstream SSE for non-stream /v1/responses requests on sub2api and aggregates the final payload', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://generic.example.com', platform: 'sub2api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-generic', + actualModel: 'gpt-5.2-codex', + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_sub2api_forced_stream","model":"gpt-5.2-codex","created_at":1706000000,"status":"in_progress","output":[]}}\n\n')); + controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_sub2api_forced_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n')); + controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_sub2api_forced_stream","delta":"hello from forced sub2api stream"}\n\n')); + controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_sub2api_forced_stream","model":"gpt-5.2-codex","status":"completed","output":[{"id":"msg_sub2api_forced_stream","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hello from forced sub2api stream"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\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: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello', + store: true, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.object).toBe('response'); + expect(body.output_text).toContain('hello from forced sub2api stream'); + + const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + const forwarded = JSON.parse(options.body); + expect(options.headers.accept).toBe('text/event-stream'); + expect(forwarded.stream).toBe(true); + expect(forwarded.store).toBe(false); + }); + it('continues downgrade to /v1/messages when /v1/chat/completions returns messages is required for /v1/responses', async () => { fetchMock .mockResolvedValueOnce(new Response(JSON.stringify({ @@ -1958,6 +2018,108 @@ describe('chat proxy stream behavior', () => { }); }); + it('preserves sub2api responses semantics across compatibility retries without using a strict field-dropping body', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-sub2api', + actualModel: 'upstream-gpt', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'upstream_error', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_sub2api_safe_headers', + object: 'response', + status: 'completed', + model: 'upstream-gpt', + output_text: 'sub2api safe header retry preserved responses semantics', + output: [], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'accept-language': 'zh-CN', + 'openai-beta': 'responses-2025-03-11', + 'x-stainless-lang': 'typescript', + originator: 'codex_cli_rs', + session_id: 'session-123', + conversation_id: 'conversation-123', + 'x-codex-turn-state': 'turn-state', + 'x-codex-turn-metadata': 'turn-metadata', + version: '0.202.0', + }, + payload: { + model: 'gpt-5.2', + input: 'hello', + previous_response_id: 'resp_prev_1', + include: ['reasoning.encrypted_content'], + reasoning: { effort: 'high' }, + prompt_cache_key: 'cache-key-1', + service_tier: 'priority', + background: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + const firstBody = JSON.parse(firstOptions.body); + const secondBody = JSON.parse(secondOptions.body); + + expect(firstOptions.headers.accept).toBe('text/event-stream'); + expect(secondOptions.headers.accept).toBe('text/event-stream'); + expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11'); + expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11'); + expect(firstOptions.headers.originator).toBe('codex_cli_rs'); + expect(secondOptions.headers.originator).toBe('codex_cli_rs'); + expect(secondOptions.headers['accept-language']).toBe('zh-CN'); + expect(secondOptions.headers.session_id).toBe('session-123'); + expect(secondOptions.headers.conversation_id).toBe('conversation-123'); + expect(secondOptions.headers['x-codex-turn-state']).toBe('turn-state'); + expect(secondOptions.headers['x-codex-turn-metadata']).toBe('turn-metadata'); + expect(secondOptions.headers['x-stainless-lang']).toBeUndefined(); + expect(secondOptions.headers.version).toBeUndefined(); + expect(secondOptions.headers['user-agent']).toBe('lightMyRequest'); + expect(firstBody).toMatchObject({ + stream: true, + store: false, + previous_response_id: 'resp_prev_1', + include: ['reasoning.encrypted_content'], + reasoning: { effort: 'high' }, + prompt_cache_key: 'cache-key-1', + service_tier: 'priority', + background: true, + }); + expect(secondBody).toMatchObject({ + stream: true, + store: false, + previous_response_id: 'resp_prev_1', + include: ['reasoning.encrypted_content'], + reasoning: { effort: 'high' }, + prompt_cache_key: 'cache-key-1', + service_tier: 'priority', + background: true, + }); + }); + it('retries generic 400 /v1/responses with minimal headers for strict compatibility fallback', async () => { fetchMock .mockResolvedValueOnce(new Response(JSON.stringify({ @@ -2063,7 +2225,7 @@ describe('chat proxy stream behavior', () => { expect(body.error?.message).not.toContain(''); }); - it('downgrades /v1/responses to /v1/chat/completions when upstream returns 400 upstream_error', async () => { + it('does not downgrade /v1/responses to /v1/chat/completions on generic 400 upstream_error without endpoint mismatch hints', async () => { fetchMock .mockResolvedValueOnce(new Response(JSON.stringify({ error: { @@ -2098,16 +2260,13 @@ describe('chat proxy stream behavior', () => { }, }); - expect(response.statusCode).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(response.statusCode).toBe(400); + expect(fetchMock).toHaveBeenCalledTimes(1); const [firstUrl] = fetchMock.mock.calls[0] as [string, any]; - const [secondUrl] = fetchMock.mock.calls[1] as [string, any]; expect(firstUrl).toContain('/v1/responses'); - expect(secondUrl).toContain('/v1/chat/completions'); const body = response.json(); - expect(body.object).toBe('response'); - expect(body.output_text).toContain('ok via chat fallback from upstream_error'); + expect(body.error?.message).toBeTruthy(); }); it('downgrades /v1/responses to /v1/chat/completions when upstream responses endpoint returns 502', async () => { @@ -3285,7 +3444,7 @@ describe('chat proxy stream behavior', () => { expect(targetUrl).toContain('/v1/messages'); }); - it('prefers /v1/messages on openai platform for claude-family models on /v1/chat/completions', async () => { + it('prefers /v1/responses on openai platform for claude-family models on /v1/chat/completions', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ { @@ -3306,11 +3465,17 @@ describe('chat proxy stream behavior', () => { }); fetchMock.mockResolvedValue(new Response(JSON.stringify({ - id: 'msg_openai_platform_claude', - type: 'message', + id: 'resp_openai_platform_claude', + object: 'response', model: 'claude-opus-4-6', - content: [{ type: 'text', text: 'messages endpoint selected' }], - stop_reason: 'end_turn', + status: 'completed', + output: [{ + id: 'msg_openai_platform_claude', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'responses endpoint selected' }], + }], usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 }, }), { status: 200, @@ -3330,10 +3495,10 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(1); const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; - expect(targetUrl).toContain('/v1/messages'); + expect(targetUrl).toContain('/v1/responses'); }); - it('falls back from /v1/messages to /v1/chat/completions on openai platform when messages endpoint is unavailable', async () => { + it('falls back from /v1/responses to /v1/messages on openai platform when responses endpoint is unavailable', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, @@ -3351,16 +3516,12 @@ describe('chat proxy stream behavior', () => { headers: { 'content-type': 'application/json' }, })) .mockResolvedValueOnce(new Response(JSON.stringify({ - id: 'chatcmpl_openai_fallback_chat', - object: 'chat.completion', - created: 1_706_000_777, + id: 'msg_openai_fallback_messages', + type: 'message', model: 'claude-opus-4-6', - choices: [{ - index: 0, - message: { role: 'assistant', content: 'fallback to chat completed' }, - finish_reason: 'stop', - }], - usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + content: [{ type: 'text', text: 'fallback to messages completed' }], + stop_reason: 'end_turn', + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -3380,7 +3541,7 @@ describe('chat proxy stream behavior', () => { expect(fetchMock).toHaveBeenCalledTimes(2); const [firstUrl] = fetchMock.mock.calls[0] as [string, any]; const [secondUrl] = fetchMock.mock.calls[1] as [string, any]; - expect(firstUrl).toContain('/v1/messages'); + expect(firstUrl).toContain('/v1/responses'); expect(secondUrl).toContain('/v1/chat/completions'); }); @@ -3558,16 +3719,19 @@ describe('chat proxy stream behavior', () => { status: 400, headers: { 'content-type': 'application/json' }, })) - .mockResolvedValueOnce(new Response(JSON.stringify({ - id: 'resp_legacy_protocol_preferred', - object: 'response', - model: 'gpt-5.2-codex', - status: 'completed', - output_text: 'ok via direct responses preference', - usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + .mockResolvedValueOnce(new Response(new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_legacy_protocol_preferred","model":"gpt-5.2-codex","created_at":1706000000,"status":"in_progress","output":[]}}\n\n')); + controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_legacy_protocol_preferred","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n')); + controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_legacy_protocol_preferred","delta":"ok via direct responses preference"}\n\n')); + controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_legacy_protocol_preferred","model":"gpt-5.2-codex","status":"completed","output":[{"id":"msg_legacy_protocol_preferred","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok via direct responses preference"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, }), { status: 200, - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, })); const response = await app.inject({ @@ -3583,13 +3747,178 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); const [firstUrl] = fetchMock.mock.calls[0] as [string, any]; - const [secondUrl] = fetchMock.mock.calls[1] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; expect(firstUrl).toContain('/v1/chat/completions'); expect(secondUrl).toContain('/v1/responses'); expect(secondUrl).not.toContain('/v1/messages'); + expect(secondOptions.headers.accept).toBe('text/event-stream'); + const forwarded = JSON.parse(secondOptions.body); + expect(forwarded.stream).toBe(true); + expect(forwarded.store).toBe(false); expect(response.json()?.choices?.[0]?.message?.content).toContain('ok via direct responses preference'); }); + it('prefers /v1/messages immediately after a generic chat endpoint says messages is required', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://generic.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-generic', + actualModel: 'gpt-5.2', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'messages is required', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'msg_messages_required_preferred_1', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'ok via messages fallback' }], + stop_reason: 'end_turn', + usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'msg_messages_required_preferred_2', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'ok via direct messages preference' }], + stop_reason: 'end_turn', + usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-5.2', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.json()?.choices?.[0]?.message?.content).toContain('ok via messages fallback'); + + const secondResponse = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-5.2', + stream: false, + messages: [{ role: 'user', content: 'hello again' }], + }, + }); + + expect(secondResponse.statusCode).toBe(200); + expect(secondResponse.json()?.choices?.[0]?.message?.content).toContain('ok via direct messages preference'); + expect(fetchMock).toHaveBeenCalledTimes(3); + const [firstUrl] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl] = fetchMock.mock.calls[1] as [string, any]; + const [thirdUrl] = fetchMock.mock.calls[2] as [string, any]; + expect(firstUrl).toContain('/v1/chat/completions'); + expect(secondUrl).toContain('/v1/messages'); + expect(thirdUrl).toContain('/v1/messages'); + expect(thirdUrl).not.toContain('/v1/chat/completions'); + }); + + it('promotes /v1/responses to the next same-request attempt when a generic chat endpoint says input is required', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://generic.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-generic', + actualModel: 'gpt-5.2', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'input is required', + type: 'invalid_request_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_input_required_preferred_1', + object: 'response', + output: [{ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok via same-request responses promotion' }], + }], + usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_input_required_preferred_2', + object: 'response', + output: [{ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok via learned direct responses preference' }], + }], + usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-5.2', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.json()?.choices?.[0]?.message?.content).toContain('ok via same-request responses promotion'); + + const secondResponse = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-5.2', + stream: false, + messages: [{ role: 'user', content: 'hello again' }], + }, + }); + + expect(secondResponse.statusCode).toBe(200); + expect(secondResponse.json()?.choices?.[0]?.message?.content).toContain('ok via learned direct responses preference'); + expect(fetchMock).toHaveBeenCalledTimes(3); + const [firstUrl] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl] = fetchMock.mock.calls[1] as [string, any]; + const [thirdUrl] = fetchMock.mock.calls[2] as [string, any]; + expect(firstUrl).toContain('/v1/chat/completions'); + expect(secondUrl).toContain('/v1/responses'); + expect(secondUrl).not.toContain('/v1/messages'); + expect(thirdUrl).toContain('/v1/responses'); + expect(thirdUrl).not.toContain('/v1/chat/completions'); + }); + it('keeps messages-first semantics for claude-family models on generic upstreams', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -3649,7 +3978,7 @@ describe('chat proxy stream behavior', () => { expect(response.json()?.choices?.[0]?.message?.content).toContain('ok via responses'); }); - it('forces openai platform to use /v1/chat/completions for claude downstream requests', async () => { + it('forces openai platform to use /v1/responses for claude downstream requests', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, @@ -3660,16 +3989,18 @@ describe('chat proxy stream behavior', () => { }); fetchMock.mockResolvedValue(new Response(JSON.stringify({ - id: 'chatcmpl-openai-for-claude-downstream', - object: 'chat.completion', - created: 1_706_000_003, + id: 'resp-openai-for-claude-downstream', + object: 'response', model: 'gpt-4o-mini', - choices: [{ - index: 0, - message: { role: 'assistant', content: 'openai endpoint selected' }, - finish_reason: 'stop', + status: 'completed', + output: [{ + id: 'msg-openai-for-claude-downstream', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'openai endpoint selected' }], }], - usage: { prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 }, + usage: { input_tokens: 4, output_tokens: 3, total_tokens: 7 }, }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -3688,11 +4019,11 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(1); const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; - expect(targetUrl).toContain('/v1/chat/completions'); + expect(targetUrl).toContain('/v1/responses'); expect(targetUrl).not.toContain('/v1/messages'); }); - it('preserves claude tool_use/tool_result when claude downstream is routed to openai chat endpoint', async () => { + it('preserves claude tool_use/tool_result when claude downstream is routed to openai responses endpoint', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, @@ -3703,16 +4034,18 @@ describe('chat proxy stream behavior', () => { }); fetchMock.mockResolvedValue(new Response(JSON.stringify({ - id: 'chatcmpl-openai-tools', - object: 'chat.completion', - created: 1_706_000_006, + id: 'resp-openai-tools', + object: 'response', model: 'gpt-4o-mini', - choices: [{ - index: 0, - message: { role: 'assistant', content: 'tool payload received' }, - finish_reason: 'stop', + status: 'completed', + output: [{ + id: 'msg-openai-tools', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'tool payload received' }], }], - usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 }, + usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13 }, }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -3758,25 +4091,22 @@ describe('chat proxy stream behavior', () => { const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; const forwardedBody = JSON.parse(options.body); - const forwardedMessages = Array.isArray(forwardedBody.messages) ? forwardedBody.messages : []; + expect(fetchMock.mock.calls[0]?.[0]).toContain('/v1/responses'); + const forwardedInput = Array.isArray(forwardedBody.input) ? forwardedBody.input : []; - const assistantWithToolCalls = forwardedMessages.find((item: any) => - item?.role === 'assistant' - && Array.isArray(item?.tool_calls) - && item.tool_calls.length > 0, - ); - expect(assistantWithToolCalls).toBeTruthy(); - expect(assistantWithToolCalls.tool_calls[0].id).toBe('toolu_abc'); - expect(assistantWithToolCalls.tool_calls[0].function?.name).toBe('Glob'); - expect(assistantWithToolCalls.tool_calls[0].function?.arguments).toContain('README*'); + const functionCall = forwardedInput.find((item: any) => item?.type === 'function_call'); + expect(functionCall).toBeTruthy(); + expect(functionCall.call_id).toBe('toolu_abc'); + expect(functionCall.name).toBe('Glob'); + expect(String(functionCall.arguments || '')).toContain('README*'); - const toolMessage = forwardedMessages.find((item: any) => item?.role === 'tool'); - expect(toolMessage).toBeTruthy(); - expect(toolMessage.tool_call_id).toBe('toolu_abc'); - expect(toolMessage.content).toContain('matches'); + const toolOutput = forwardedInput.find((item: any) => item?.type === 'function_call_output'); + expect(toolOutput).toBeTruthy(); + expect(toolOutput.call_id).toBe('toolu_abc'); + expect(String(toolOutput.output || '')).toContain('matches'); }); - it('maps claude tool config and thinking budget before routing claude downstream requests to openai chat endpoint', async () => { + it('maps claude tool config and thinking budget before routing claude downstream requests to openai responses endpoint', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, @@ -3787,16 +4117,18 @@ describe('chat proxy stream behavior', () => { }); fetchMock.mockResolvedValue(new Response(JSON.stringify({ - id: 'chatcmpl-openai-config-mapped', - object: 'chat.completion', - created: 1_706_000_007, + id: 'resp-openai-config-mapped', + object: 'response', model: 'gpt-4o-mini', - choices: [{ - index: 0, - message: { role: 'assistant', content: 'tool config mapped' }, - finish_reason: 'stop', + status: 'completed', + output: [{ + id: 'msg-openai-config-mapped', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'tool config mapped' }], }], - usage: { prompt_tokens: 9, completion_tokens: 3, total_tokens: 12 }, + usage: { input_tokens: 9, output_tokens: 3, total_tokens: 12 }, }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -3833,27 +4165,26 @@ describe('chat proxy stream behavior', () => { const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; const forwardedBody = JSON.parse(options.body); + expect(fetchMock.mock.calls[0]?.[0]).toContain('/v1/responses'); expect(forwardedBody.metadata).toEqual({ user_id: 'user-1' }); - expect(forwardedBody.reasoning_budget).toBe(1024); + expect(forwardedBody.reasoning).toEqual({ + budget_tokens: 1024, + }); expect(forwardedBody.tools).toEqual([{ type: 'function', - function: { - name: 'Glob', - description: 'Search files', - parameters: { - type: 'object', - properties: { - pattern: { type: 'string' }, - }, - required: ['pattern'], + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, }, + required: ['pattern'], }, }]); expect(forwardedBody.tool_choice).toEqual({ type: 'function', - function: { - name: 'Glob', - }, + name: 'Glob', }); }); @@ -4192,7 +4523,7 @@ describe('chat proxy stream behavior', () => { expect(matches.length).toBe(1); }); - it('emits finish_reason error when /v1/chat/completions receives response.failed from /v1/responses upstream', async () => { + it('emits finish_reason stop when /v1/chat/completions receives response.failed from /v1/responses upstream', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ { @@ -4229,7 +4560,7 @@ describe('chat proxy stream behavior', () => { }); expect(response.statusCode).toBe(200); - expect(response.body).toContain('"finish_reason":"error"'); + expect(response.body).toContain('"finish_reason":"stop"'); expect(response.body).toContain('[DONE]'); expect(recordSuccessMock).not.toHaveBeenCalled(); expect(recordFailureMock).toHaveBeenCalledTimes(1); diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 2e0da68..b212d21 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -7,6 +7,8 @@ import { export async function chatProxyRoute(app: FastifyInstance) { app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => handleChatSurfaceRequest(request, reply, 'openai')); + app.post('/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => + handleChatSurfaceRequest(request, reply, 'openai')); } export async function claudeMessagesProxyRoute(app: FastifyInstance) { diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 56ba881..379dfed 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -12,7 +12,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { detectProxyFailure } from './proxyFailureJudge.js'; +import { detectProxyFailure } from '../../services/proxyFailureJudge.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; diff --git a/src/server/routes/proxy/downstreamClientContext.test.ts b/src/server/routes/proxy/downstreamClientContext.test.ts index baa3a20..fc9faba 100644 --- a/src/server/routes/proxy/downstreamClientContext.test.ts +++ b/src/server/routes/proxy/downstreamClientContext.test.ts @@ -80,6 +80,22 @@ describe('detectDownstreamClientContext', () => { }); }); + it('recognizes conversation_id-only Codex requests as continuation-capable sessions', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + conversation_id: 'codex-conversation-123', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'codex', + clientAppName: 'Codex', + clientConfidence: 'heuristic', + sessionId: 'codex-conversation-123', + traceHint: 'codex-conversation-123', + }); + }); + it('recognizes broader Codex official-client user-agent families without requiring stainless headers', () => { expect(detectDownstreamClientContext({ downstreamPath: '/v1/responses', diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index 7b18bc1..d05a640 100644 --- a/src/server/routes/proxy/gemini.test.ts +++ b/src/server/routes/proxy/gemini.test.ts @@ -799,23 +799,22 @@ describe('gemini native proxy routes', () => { actualModel: 'gpt-4.1', }); fetchMock.mockResolvedValue(new Response(JSON.stringify({ - id: 'chatcmpl-openai-1', - object: 'chat.completion', - created: 1_742_160_000, + id: 'resp-openai-1', + object: 'response', model: 'gpt-4.1', - choices: [ + status: 'completed', + output: [ { - index: 0, - message: { - role: 'assistant', - content: 'hello from openai', - }, - finish_reason: 'stop', + id: 'msg-openai-1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from openai' }], }, ], usage: { - prompt_tokens: 4, - completion_tokens: 3, + input_tokens: 4, + output_tokens: 3, total_tokens: 7, }, }), { @@ -841,7 +840,7 @@ describe('gemini native proxy routes', () => { expect(response.statusCode).toBe(200); const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions'); + expect(targetUrl).toBe('https://api.openai.com/v1/responses'); expect(requestInit.headers).toMatchObject({ Authorization: 'Bearer openai-access-token', 'Content-Type': 'application/json', @@ -862,21 +861,28 @@ describe('gemini native proxy routes', () => { expect.objectContaining({ traceId: 801 }), expect.objectContaining({ finalStatus: 'success', - finalUpstreamPath: '/v1/chat/completions', + finalUpstreamPath: '/v1/responses', }), ); expect(JSON.parse(String(requestInit.body))).toEqual({ model: 'gpt-4.1', stream: false, - messages: [ + max_output_tokens: 4096, + input: [ { + type: 'message', role: 'user', - content: 'hello', + content: [ + { + type: 'input_text', + text: 'hello', + }, + ], }, ], }); expect(response.json()).toEqual({ - responseId: 'chatcmpl-openai-1', + responseId: 'resp-openai-1', modelVersion: 'gpt-4.1', candidates: [ { @@ -906,23 +912,22 @@ describe('gemini native proxy routes', () => { actualModel: 'gpt-4.1', }); fetchMock.mockResolvedValue(new Response(JSON.stringify({ - id: 'chatcmpl-openai-stream-1', - object: 'chat.completion', - created: 1_742_160_003, + id: 'resp-openai-stream-1', + object: 'response', model: 'gpt-4.1', - choices: [ + status: 'completed', + output: [ { - index: 0, - message: { - role: 'assistant', - content: 'hello from openai stream fallback', - }, - finish_reason: 'stop', + id: 'msg-openai-stream-1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from openai stream fallback' }], }, ], usage: { - prompt_tokens: 5, - completion_tokens: 6, + input_tokens: 5, + output_tokens: 6, total_tokens: 11, }, }), { @@ -950,7 +955,7 @@ describe('gemini native proxy routes', () => { expect(response.headers['content-type']).toContain('text/event-stream'); expect(parseSsePayloads(response.body)).toEqual([ { - responseId: 'chatcmpl-openai-stream-1', + responseId: 'resp-openai-stream-1', modelVersion: 'gpt-4.1', candidates: [ { @@ -981,23 +986,22 @@ describe('gemini native proxy routes', () => { actualModel: 'gpt-4.1', }); fetchMock.mockResolvedValue(new Response(JSON.stringify({ - id: 'chatcmpl-openai-2', - object: 'chat.completion', - created: 1_742_160_001, + id: 'resp-openai-2', + object: 'response', model: 'gpt-4.1', - choices: [ + status: 'completed', + output: [ { - index: 0, - message: { - role: 'assistant', - content: 'hello from gemini cli downstream', - }, - finish_reason: 'stop', + id: 'msg-openai-2', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from gemini cli downstream' }], }, ], usage: { - prompt_tokens: 6, - completion_tokens: 5, + input_tokens: 6, + output_tokens: 5, total_tokens: 11, }, }), { @@ -1024,20 +1028,17 @@ describe('gemini native proxy routes', () => { expect(response.statusCode).toBe(200); const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions'); - expect(JSON.parse(String(requestInit.body))).toEqual({ + expect(targetUrl).toBe('https://api.openai.com/v1/responses'); + const forwardedBody = JSON.parse(String(requestInit.body)); + expect(forwardedBody).toMatchObject({ model: 'gpt-4.1', stream: false, - messages: [ - { - role: 'user', - content: 'hello', - }, - ], }); + expect(Array.isArray(forwardedBody.input)).toBe(true); + expect(JSON.stringify(forwardedBody.input)).toContain('hello'); expect(response.json()).toEqual({ response: { - responseId: 'chatcmpl-openai-2', + responseId: 'resp-openai-2', modelVersion: 'gpt-4.1', candidates: [ { diff --git a/src/server/routes/proxy/logPathMeta.ts b/src/server/routes/proxy/logPathMeta.ts index f2a2f50..d2396eb 100644 --- a/src/server/routes/proxy/logPathMeta.ts +++ b/src/server/routes/proxy/logPathMeta.ts @@ -1,51 +1,5 @@ -import { - parseProxyLogMetadata, - type ParsedProxyLogMetadata, - type ProxyLogUsageSource, -} from '../../../shared/proxyLogMeta.js'; - -type ComposeProxyLogMessageArgs = { - clientKind?: string | null; - sessionId?: string | null; - traceHint?: string | null; - downstreamPath?: string | null; - upstreamPath?: string | null; - usageSource?: ProxyLogUsageSource; - errorMessage?: string | null; -}; - -export type ParsedProxyLogMessageMeta = ParsedProxyLogMetadata; - -export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta { - return parseProxyLogMetadata(rawMessage); -} - -export function composeProxyLogMessage({ - clientKind, - sessionId, - traceHint, - downstreamPath, - upstreamPath, - usageSource, - errorMessage, -}: ComposeProxyLogMessageArgs): string | null { - const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : ''; - const parsed = parseProxyLogMessageMeta(rawMessage); - const finalClientKind = (clientKind || parsed.clientKind || '').trim(); - const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim(); - const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim(); - const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim(); - const finalUsageSource = (usageSource || parsed.usageSource || '').trim(); - const finalMessageText = parsed.messageText.trim(); - - const prefixParts: string[] = []; - if (finalClientKind) prefixParts.push(`[client:${finalClientKind}]`); - if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`); - if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`); - if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`); - if (finalUsageSource) prefixParts.push(`[usage:${finalUsageSource}]`); - - if (prefixParts.length === 0 && !finalMessageText) return null; - if (finalMessageText) return `${prefixParts.join(' ')} ${finalMessageText}`.trim(); - return prefixParts.join(' '); -} +export { + composeProxyLogMessage, + parseProxyLogMessageMeta, + type ParsedProxyLogMessageMeta, +} from '../../services/proxyLogMessage.js'; diff --git a/src/server/routes/proxy/proxyBilling.ts b/src/server/routes/proxy/proxyBilling.ts index b7a02c4..1039878 100644 --- a/src/server/routes/proxy/proxyBilling.ts +++ b/src/server/routes/proxy/proxyBilling.ts @@ -1,92 +1 @@ -import { - buildProxyBillingDetails, - estimateProxyCost, - type ProxyBillingDetails, - type ProxyBillingPricingOverride, -} from '../../services/modelPricingService.js'; -import type { SelfLogBillingMeta } from '../../services/proxyUsageFallbackService.js'; - -interface ProxyBillingUsageSummary { - promptTokens: number; - completionTokens: number; - totalTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - promptTokensIncludeCache: boolean | null; -} - -interface ResolvedProxyUsageSummary { - promptTokens: number; - completionTokens: number; - totalTokens: number; - recoveredFromSelfLog: boolean; - estimatedCostFromQuota: number; - selfLogBillingMeta: SelfLogBillingMeta | null; -} - -interface ResolveProxyLogBillingInput { - site: { - id: number; - url: string; - platform: string; - apiKey?: string | null; - }; - account: { - id: number; - accessToken?: string | null; - apiToken?: string | null; - }; - modelName: string; - parsedUsage: ProxyBillingUsageSummary; - resolvedUsage: ResolvedProxyUsageSummary; -} - -function toPricingOverride(meta: SelfLogBillingMeta | null): ProxyBillingPricingOverride | null { - if (!meta) return null; - return { - modelRatio: meta.modelRatio, - completionRatio: meta.completionRatio, - cacheRatio: meta.cacheRatio, - cacheCreationRatio: meta.cacheCreationRatio, - groupRatio: meta.groupRatio, - }; -} - -export async function resolveProxyLogBilling( - input: ResolveProxyLogBillingInput, -): Promise<{ estimatedCost: number; billingDetails: ProxyBillingDetails | null }> { - const selfLogMeta = input.resolvedUsage.selfLogBillingMeta; - const billingPricingOverride = toPricingOverride(selfLogMeta); - const cacheReadTokens = selfLogMeta?.cacheReadTokens ?? input.parsedUsage.cacheReadTokens; - const cacheCreationTokens = selfLogMeta?.cacheCreationTokens ?? input.parsedUsage.cacheCreationTokens; - const promptTokensIncludeCache = selfLogMeta?.promptTokensIncludeCache - ?? input.parsedUsage.promptTokensIncludeCache; - - const billingInput = { - site: input.site, - account: input.account, - modelName: input.modelName, - promptTokens: input.resolvedUsage.promptTokens, - completionTokens: input.resolvedUsage.completionTokens, - totalTokens: input.resolvedUsage.totalTokens, - cacheReadTokens, - cacheCreationTokens, - promptTokensIncludeCache, - billingPricingOverride, - }; - - let estimatedCost = await estimateProxyCost(billingInput); - const billingDetails = await buildProxyBillingDetails(billingInput); - - if ( - input.resolvedUsage.estimatedCostFromQuota > 0 - && (input.resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0) - ) { - estimatedCost = input.resolvedUsage.estimatedCostFromQuota; - } - - return { - estimatedCost, - billingDetails, - }; -} +export { resolveProxyLogBilling } from '../../services/proxyBilling.js'; diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts index 2534b68..9301706 100644 --- a/src/server/routes/proxy/responses.codex-oauth.test.ts +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -20,7 +20,8 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => recoveredFromSelfLog: false, })); const refreshOauthAccessTokenSingleflightMock = vi.fn(); -const recordOauthQuotaResetHintMock = vi.fn(); +const recordOauthQuotaHeadersSnapshotMock = vi.fn<(input: unknown) => Promise>(async (_input) => undefined); +const recordOauthQuotaResetHintMock = vi.fn<(input: unknown) => Promise>(async (_input) => undefined); const insertedProxyLogs: Record[] = []; const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled; const originalProxyStickySessionEnabled = config.proxyStickySessionEnabled; @@ -35,6 +36,8 @@ const dbInsertMock = vi.fn((_arg?: any) => ({ }, })); +const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; + vi.mock('undici', async () => { const actual = await vi.importActual('undici'); return { @@ -87,7 +90,8 @@ vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ })); vi.mock('../../services/oauth/quota.js', () => ({ - recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args), + recordOauthQuotaHeadersSnapshot: async (input: unknown) => recordOauthQuotaHeadersSnapshotMock(input), + recordOauthQuotaResetHint: async (input: unknown) => recordOauthQuotaResetHintMock(input), })); vi.mock('../../db/index.js', () => ({ @@ -197,7 +201,8 @@ describe('responses proxy codex oauth refresh', () => { reportTokenExpiredMock.mockReset(); resolveProxyUsageWithSelfLogFallbackMock.mockClear(); refreshOauthAccessTokenSingleflightMock.mockReset(); - recordOauthQuotaResetHintMock.mockReset(); + recordOauthQuotaHeadersSnapshotMock.mockClear(); + recordOauthQuotaResetHintMock.mockClear(); dbInsertMock.mockClear(); insertedProxyLogs.length = 0; @@ -335,6 +340,68 @@ describe('responses proxy codex oauth refresh', () => { expect(response.json()?.output_text).toContain('ok after codex forbidden refresh'); }); + it('infers official codex originator from desktop user-agent when downstream originator is absent', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_desktop_originator', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'desktop originator preserved', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'hello desktop codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, any]; + expect(options.headers.Originator || options.headers.originator).toBe('codex_chatgpt_desktop'); + }); + + it('canonicalizes official codex originator aliases before proxying upstream', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_originator_alias', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'originator alias preserved', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + originator: 'Codex Desktop', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, any]; + expect(options.headers.Originator || options.headers.originator).toBe('codex_chatgpt_desktop'); + }); + it('does not refresh codex oauth token on non-auth 403 responses', async () => { fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: { message: 'quota exceeded for workspace', type: 'usage_limit_reached' }, @@ -425,7 +492,7 @@ describe('responses proxy codex oauth refresh', () => { expect(response.json()?.output_text).toBe('ok after refresh'); }); - it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => { + it('sends a non-empty default instructions field to codex responses when downstream body has no system prompt', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: 'resp_codex_no_system', object: 'response', @@ -452,7 +519,7 @@ describe('responses proxy codex oauth refresh', () => { const [, options] = fetchMock.mock.calls[0] as [string, any]; const forwardedBody = JSON.parse(options.body); - expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS); expect(forwardedBody.prompt_cache_key).toBeUndefined(); expect(forwardedBody.stream).toBe(true); expect(forwardedBody.input).toEqual([ @@ -464,6 +531,53 @@ describe('responses proxy codex oauth refresh', () => { ]); }); + it('extracts codex system input into top-level instructions before proxying upstream', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_codex_system_to_instructions', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok with extracted instructions', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + instructions: 'keep edits narrow', + input: [ + { + type: 'message', + role: 'system', + content: [{ type: 'input_text', text: 'be precise' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello codex' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.instructions).toBe('be precise\n\nkeep edits narrow'); + expect(forwardedBody.input).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello codex' }], + }, + ]); + }); + it('preserves explicit prompt_cache_key for codex responses requests without converting it into codex session headers', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: 'resp_codex_with_cache_key', @@ -571,6 +685,75 @@ describe('responses proxy codex oauth refresh', () => { ]); }); + it('infers previous_response_id for codex tool-output follow-up turns when the client only sends conversation_id', async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_prev_conv_1', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'tool call issued', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_prev_conv_2', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'tool result accepted', + usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const headers = { + conversation_id: 'conversation-http-prev-1', + 'user-agent': 'CodexClient/1.0', + }; + + const firstResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers, + payload: { + model: 'gpt-5.2-codex', + input: 'start codex tool flow', + }, + }); + expect(firstResponse.statusCode).toBe(200); + + const secondResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers, + payload: { + model: 'gpt-5.2-codex', + input: [ + { + id: 'tool_out_conv_1', + type: 'function_call_output', + call_id: 'call_conv_1', + output: '{"ok":true}', + }, + ], + }, + }); + + expect(secondResponse.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + const secondBody = JSON.parse(secondOptions.body); + expect(firstOptions.headers.Session_id || firstOptions.headers.session_id).toBe('conversation-http-prev-1'); + expect(secondOptions.headers.Session_id || secondOptions.headers.session_id).toBe('conversation-http-prev-1'); + expect(secondBody.previous_response_id).toBe('resp_codex_prev_conv_1'); + }); + it('reuses previous_response_id for codex tool-output follow-up turns after channel/account drift on the same downstream session', async () => { const firstSelected = { channel: { id: 11, routeId: 22 }, @@ -1004,7 +1187,7 @@ describe('responses proxy codex oauth refresh', () => { const [, options] = fetchMock.mock.calls[0] as [string, any]; const forwardedBody = JSON.parse(options.body); expect(forwardedBody.stream).toBe(true); - expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS); expect(forwardedBody.store).toBe(false); expect(forwardedBody.max_output_tokens).toBeUndefined(); @@ -1148,11 +1331,11 @@ describe('responses proxy codex oauth refresh', () => { const firstBody = JSON.parse(firstOptions.body); const secondBody = JSON.parse(secondOptions.body); - expect(firstBody.instructions).toBe(''); + expect(firstBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS); expect(firstBody.store).toBe(false); expect(firstBody.stream).toBe(true); expect(firstBody.max_output_tokens).toBeUndefined(); - expect(secondBody.instructions).toBe(''); + expect(secondBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS); expect(secondBody.store).toBe(false); expect(secondBody.stream).toBe(true); expect(secondBody.max_output_tokens).toBeUndefined(); diff --git a/src/server/routes/proxy/responses.compact-upstream.test.ts b/src/server/routes/proxy/responses.compact-upstream.test.ts index d7dc3c1..e2e0203 100644 --- a/src/server/routes/proxy/responses.compact-upstream.test.ts +++ b/src/server/routes/proxy/responses.compact-upstream.test.ts @@ -25,6 +25,8 @@ const dbInsertMock = vi.fn((_arg?: any) => ({ }), })); +const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; + vi.mock('undici', async () => { const actual = await vi.importActual('undici'); return { @@ -177,7 +179,7 @@ describe('responses proxy compact upstream routing', () => { expect(targetUrl).toContain('/v1/responses/compact'); }); - it('keeps native compact as the default even for generic openai-compatible upstreams', async () => { + it('automatically falls back from compact to ordinary responses for generic openai-compatible upstreams when compact is unsupported', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' }, @@ -186,15 +188,25 @@ describe('responses proxy compact upstream routing', () => { tokenValue: 'sk-demo', actualModel: 'upstream-gpt', }); - fetchMock.mockResolvedValue(new Response(JSON.stringify({ - error: { - message: 'Invalid URL (POST /v1/responses/compact)', - type: 'invalid_request_error', - }, - }), { - status: 404, - headers: { 'content-type': 'application/json' }, - })); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'Invalid URL (POST /v1/responses/compact)', + type: 'invalid_request_error', + }, + }), { + status: 404, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_123', + object: 'response', + output_text: 'hello from fallback', + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); const response = await app.inject({ method: 'POST', @@ -205,10 +217,19 @@ describe('responses proxy compact upstream routing', () => { }, }); - expect(response.statusCode).toBe(404); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; - expect(targetUrl).toContain('/v1/responses/compact'); + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact'); + expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('/v1/responses'); + expect(String(fetchMock.mock.calls[1]?.[0] || '')).not.toContain('/compact'); + expect(response.json()).toMatchObject({ + object: 'response.compaction', + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }); }); it('optionally falls back from compact to ordinary responses when compact is explicitly unsupported', async () => { @@ -260,6 +281,117 @@ describe('responses proxy compact upstream routing', () => { }); }); + it('automatically falls back from compact when a generic upstream rejects the stream parameter on the compact endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: "Unknown parameter: 'stream'.", + type: 'invalid_request_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_stream_fallback', + object: 'response', + output_text: 'fallback after stream validation error', + usage: { input_tokens: 12, output_tokens: 3, total_tokens: 15 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact'); + expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('/v1/responses'); + expect(String(fetchMock.mock.calls[1]?.[0] || '')).not.toContain('/compact'); + expect(response.json()).toMatchObject({ + object: 'response.compaction', + output: expect.any(Array), + }); + }); + + it('rebuilds compact fallback as an SSE-capable non-compact /v1/responses request for sub2api', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' }, + account: { id: 33, username: 'sub2api-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'gpt-5.4', + }); + const encoder = new TextEncoder(); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'Invalid URL (POST /v1/responses/compact)', + type: 'invalid_request_error', + }, + }), { + status: 404, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_sub2api_compact_fallback","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n')); + controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_sub2api_compact_fallback","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n')); + controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_sub2api_compact_fallback","delta":"fallback via sub2api responses stream"}\n\n')); + controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_sub2api_compact_fallback","model":"gpt-5.4","status":"completed","output":[{"id":"msg_sub2api_compact_fallback","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"fallback via sub2api responses stream"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }), { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.4', + input: 'hello', + store: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact'); + expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('/v1/responses'); + expect(String(fetchMock.mock.calls[1]?.[0] || '')).not.toContain('/compact'); + const secondOptions = fetchMock.mock.calls[1]?.[1] as Record; + const secondBody = JSON.parse(String(secondOptions?.body || '{}')); + expect(secondOptions?.headers?.accept).toBe('text/event-stream'); + expect(secondBody).toMatchObject({ + stream: true, + store: false, + }); + expect(response.json()).toMatchObject({ + object: 'response.compaction', + output: expect.any(Array), + }); + }); + it('does not fall back for unrelated unsupported errors that do not mention compact', async () => { config.responsesCompactFallbackToResponsesEnabled = true; selectChannelMock.mockReturnValue({ @@ -304,7 +436,7 @@ describe('responses proxy compact upstream routing', () => { expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact'); }); - it('strips stream fields when forwarding compact requests to codex upstreams', async () => { + it('strips stream fields and store when forwarding compact requests to codex upstreams', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, site: { id: 44, name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' }, @@ -360,8 +492,57 @@ describe('responses proxy compact upstream routing', () => { const forwardedBody = JSON.parse(String(options.body)); expect(forwardedBody.stream).toBeUndefined(); expect(forwardedBody.stream_options).toBeUndefined(); - expect(forwardedBody.instructions).toBe(''); - expect(forwardedBody.store).toBe(false); + expect(forwardedBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS); + expect(forwardedBody.store).toBeUndefined(); + expect(options.headers.Accept || options.headers.accept).toBe('application/json'); + }); + + it('strips stream fields and store when forwarding compact requests to sub2api upstreams', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' }, + account: { id: 33, username: 'sub2api-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'gpt-5.4', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'cmp_sub2api_123', + object: 'response.compaction', + input_tokens: 4, + output_tokens: 2, + total_tokens: 6, + output: [ + { + id: 'rs_sub2api_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.4', + input: 'hello', + store: true, + stream_options: { include_obfuscation: true }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/responses/compact'); + const forwardedBody = JSON.parse(String(options.body)); + expect(forwardedBody.stream).toBeUndefined(); + expect(forwardedBody.stream_options).toBeUndefined(); + expect(forwardedBody.store).toBeUndefined(); expect(options.headers.Accept || options.headers.accept).toBe('application/json'); }); @@ -525,4 +706,62 @@ describe('responses proxy compact upstream routing', () => { }); expect(body.created_at).toEqual(expect.any(Number)); }); + + it('does not fall back from compact on generic upstream errors without compact capability hints', async () => { + config.responsesCompactFallbackToResponsesEnabled = true; + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'upstream_error', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(400); + expect(fetchMock.mock.calls.length).toBeGreaterThan(0); + for (const [targetUrl] of fetchMock.mock.calls as Array<[string, any]>) { + expect(String(targetUrl)).toContain('/v1/responses/compact'); + expect(String(targetUrl)).not.toMatch(/\/v1\/responses$/); + } + }); + + it('does not fall back from compact on generic invalid url errors that do not mention compact', async () => { + config.responsesCompactFallbackToResponsesEnabled = true; + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'Invalid URL (POST /v1/responses)', + type: 'invalid_request_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(400); + expect(fetchMock.mock.calls.length).toBeGreaterThan(0); + for (const [targetUrl] of fetchMock.mock.calls as Array<[string, any]>) { + expect(String(targetUrl)).toContain('/v1/responses/compact'); + expect(String(targetUrl)).not.toMatch(/\/v1\/responses$/); + } + }); }); diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index a27395b..96e802a 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -2,6 +2,16 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { handleOpenAiResponsesSurfaceRequest } from '../../proxy-core/surfaces/openAiResponsesSurface.js'; import { ensureResponsesWebsocketTransport } from './responsesWebsocket.js'; +function resolveAliasedResponsesDownstreamPath( + request: FastifyRequest, +): '/v1/responses' | '/v1/responses/compact' { + const rawUrl = request.raw.url || request.url || ''; + const pathname = rawUrl.split('?')[0] || rawUrl; + return pathname.endsWith('/compact') + ? '/v1/responses/compact' + : '/v1/responses'; +} + export async function responsesProxyRoute(app: FastifyInstance) { ensureResponsesWebsocketTransport(app); @@ -16,4 +26,16 @@ export async function responsesProxyRoute(app: FastifyInstance) { })); app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) => handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses/compact')); + + app.post('/responses', async (request: FastifyRequest, reply: FastifyReply) => + handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses')); + app.post('/responses/*', async (request: FastifyRequest, reply: FastifyReply) => + handleOpenAiResponsesSurfaceRequest(request, reply, resolveAliasedResponsesDownstreamPath(request))); + app.get('/responses', async (_request: FastifyRequest, reply: FastifyReply) => + reply.code(426).send({ + error: { + message: 'WebSocket upgrade required for GET /responses', + type: 'invalid_request_error', + }, + })); } diff --git a/src/server/routes/proxy/responses.websocket.test.ts b/src/server/routes/proxy/responses.websocket.test.ts index 45a8ee5..13a36fe 100644 --- a/src/server/routes/proxy/responses.websocket.test.ts +++ b/src/server/routes/proxy/responses.websocket.test.ts @@ -96,6 +96,7 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({ })); vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaHeadersSnapshot: async () => undefined, recordOauthQuotaResetHint: async () => undefined, })); @@ -194,6 +195,16 @@ function waitForSocketOpen(socket: WebSocket) { }); } +function waitForSocketClose(socket: WebSocket) { + return new Promise((resolve) => { + if (socket.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + socket.once('close', () => resolve()); + }); +} + function waitForSocketUpgrade(socket: WebSocket) { return new Promise<{ headers: Record }>((resolve, reject) => { socket.once('upgrade', (response) => resolve({ headers: response.headers as Record })); @@ -748,6 +759,131 @@ describe('responses websocket transport', () => { }); }); + it('infers previous_response_id for websocket tool-output follow-up turns when the client only sends conversation_id', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + + const socket = createClientSocket(baseUrl, { + conversation_id: 'ws-conversation-prev-infer', + }); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + const firstResponse = await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_2', + ); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [ + { + id: 'tool_out_ws_conv_1', + type: 'function_call_output', + call_id: 'call_ws_conv_1', + output: '{"ok":true}', + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(firstResponse?.response?.id).toBe('resp_upstream_1'); + expect(upstreamUpgradeHeaders.session_id).toBe('ws-conversation-prev-infer'); + expect(upstreamUpgradeHeaders.conversation_id).toBe('ws-conversation-prev-infer'); + expect(upstreamRequests).toHaveLength(2); + expect(upstreamRequests[1]).toMatchObject({ + type: 'response.create', + previous_response_id: 'resp_upstream_1', + input: [ + { + id: 'tool_out_ws_conv_1', + type: 'function_call_output', + call_id: 'call_ws_conv_1', + output: '{"ok":true}', + }, + ], + }); + }); + + it('preserves websocket continuation across downstream reconnects on the same conversation_id', async () => { + const selectedChannel = createSelectedChannel({ + siteUrl: upstreamSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + + const firstSocket = createClientSocket(baseUrl, { + conversation_id: 'ws-conversation-reconnect-1', + }); + await waitForSocketOpen(firstSocket); + + const firstResponsePromise = waitForSocketMessageMatching( + firstSocket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_1', + ); + firstSocket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [], + })); + await firstResponsePromise; + firstSocket.close(); + await waitForSocketClose(firstSocket); + + const secondSocket = createClientSocket(baseUrl, { + conversation_id: 'ws-conversation-reconnect-1', + }); + await waitForSocketOpen(secondSocket); + + const secondResponsePromise = waitForSocketMessageMatching( + secondSocket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_2', + ); + secondSocket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [ + { + id: 'tool_out_ws_reconnect_1', + type: 'function_call_output', + call_id: 'call_ws_reconnect_1', + output: '{"ok":true}', + }, + ], + })); + await secondResponsePromise; + secondSocket.close(); + + expect(upstreamConnectionCount).toBe(2); + expect(upstreamRequests).toHaveLength(2); + expect(upstreamRequests[1]).toMatchObject({ + type: 'response.create', + previous_response_id: 'resp_upstream_1', + input: [ + { + id: 'tool_out_ws_reconnect_1', + type: 'function_call_output', + call_id: 'call_ws_reconnect_1', + output: '{"ok":true}', + }, + ], + }); + }); + it('retries websocket turns once without previous_response_id when the upstream reports previous_response_not_found', async () => { const selectedChannel = createSelectedChannel({ siteUrl: upstreamSiteUrl, diff --git a/src/server/routes/proxy/responsesWebsocket.ts b/src/server/routes/proxy/responsesWebsocket.ts index dde198c..e1c3cdc 100644 --- a/src/server/routes/proxy/responsesWebsocket.ts +++ b/src/server/routes/proxy/responsesWebsocket.ts @@ -554,6 +554,8 @@ async function handleResponsesWebsocketConnection( ) { const websocketSessionId = headerValueToTrimmedString(request.headers['session_id']) || headerValueToTrimmedString(request.headers['session-id']) + || headerValueToTrimmedString(request.headers['conversation_id']) + || headerValueToTrimmedString(request.headers['conversation-id']) || randomUUID(); const runtimeSessionKeys = new Set(); let lastRequest: Record | null = null; diff --git a/src/server/routes/proxy/routeAliases.test.ts b/src/server/routes/proxy/routeAliases.test.ts new file mode 100644 index 0000000..fab93ca --- /dev/null +++ b/src/server/routes/proxy/routeAliases.test.ts @@ -0,0 +1,112 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const handleOpenAiResponsesSurfaceRequestMock = vi.fn(async (_request, reply, downstreamPath) => ( + reply.code(200).send({ + ok: true, + downstreamPath, + }) +)); +const handleChatSurfaceRequestMock = vi.fn(async (_request, reply, downstreamFormat) => ( + reply.code(200).send({ + ok: true, + downstreamFormat, + }) +)); +const ensureResponsesWebsocketTransportMock = vi.fn(); + +vi.mock('../../proxy-core/surfaces/openAiResponsesSurface.js', () => ({ + handleOpenAiResponsesSurfaceRequest: (...args: unknown[]) => handleOpenAiResponsesSurfaceRequestMock(...args), +})); + +vi.mock('../../proxy-core/surfaces/chatSurface.js', () => ({ + handleChatSurfaceRequest: (...args: unknown[]) => handleChatSurfaceRequestMock(...args), + handleClaudeCountTokensSurfaceRequest: vi.fn(), +})); + +vi.mock('./responsesWebsocket.js', () => ({ + ensureResponsesWebsocketTransport: (...args: unknown[]) => ensureResponsesWebsocketTransportMock(...args), +})); + +describe('proxy route aliases', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { responsesProxyRoute } = await import('./responses.js'); + const { chatProxyRoute } = await import('./chat.js'); + app = Fastify(); + await app.register(responsesProxyRoute); + await app.register(chatProxyRoute); + }); + + beforeEach(() => { + handleOpenAiResponsesSurfaceRequestMock.mockClear(); + handleChatSurfaceRequestMock.mockClear(); + ensureResponsesWebsocketTransportMock.mockClear(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('registers bare responses aliases against the same downstream handlers', async () => { + const response = await app.inject({ + method: 'POST', + url: '/responses', + payload: { model: 'gpt-5.2', input: 'hello' }, + }); + + expect(response.statusCode).toBe(200); + expect(handleOpenAiResponsesSurfaceRequestMock).toHaveBeenCalledTimes(1); + expect(response.json()).toEqual({ + ok: true, + downstreamPath: '/v1/responses', + }); + }); + + it('routes bare compact responses aliases to compact downstream path', async () => { + const response = await app.inject({ + method: 'POST', + url: '/responses/compact', + payload: { model: 'gpt-5.2', input: 'hello' }, + }); + + expect(response.statusCode).toBe(200); + expect(handleOpenAiResponsesSurfaceRequestMock).toHaveBeenCalledTimes(1); + expect(response.json()).toEqual({ + ok: true, + downstreamPath: '/v1/responses/compact', + }); + }); + + it('returns websocket upgrade required for bare GET /responses', async () => { + const response = await app.inject({ + method: 'GET', + url: '/responses', + }); + + expect(response.statusCode).toBe(426); + expect(handleOpenAiResponsesSurfaceRequestMock).not.toHaveBeenCalled(); + expect(response.json()).toEqual({ + error: { + message: 'WebSocket upgrade required for GET /responses', + type: 'invalid_request_error', + }, + }); + }); + + it('registers bare chat completions alias against the openai chat handler', async () => { + const response = await app.inject({ + method: 'POST', + url: '/chat/completions', + payload: { model: 'gpt-5.2', messages: [{ role: 'user', content: 'hello' }] }, + }); + + expect(response.statusCode).toBe(200); + expect(handleChatSurfaceRequestMock).toHaveBeenCalledTimes(1); + expect(response.json()).toEqual({ + ok: true, + downstreamFormat: 'openai', + }); + }); +}); diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index c3f91c0..56c870c 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -25,6 +25,8 @@ import { MODEL_KEY_HASH_SUFFIX_LENGTH, } from '../../services/upstreamEndpointRuntimeMemory.js'; +const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; + const baseContext = { site: { id: 1, @@ -146,7 +148,7 @@ describe('resolveUpstreamEndpointCandidates', () => { 'gpt-5.3', 'openai', ); - expect(openaiOrder).toEqual(['chat', 'responses', 'messages']); + expect(openaiOrder).toEqual(['responses', 'chat', 'messages']); const openaiResponsesOrder = await resolveUpstreamEndpointCandidates( { @@ -166,7 +168,17 @@ describe('resolveUpstreamEndpointCandidates', () => { 'claude-opus-4-6', 'openai', ); - expect(openaiClaudeOrder).toEqual(['messages', 'chat', 'responses']); + expect(openaiClaudeOrder).toEqual(['responses', 'chat', 'messages']); + + const antigravityOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'antigravity' }, + }, + 'claude-opus-4-6', + 'openai', + ); + expect(antigravityOrder).toEqual(['messages']); const claudeOrder = await resolveUpstreamEndpointCandidates( { @@ -203,7 +215,63 @@ describe('resolveUpstreamEndpointCandidates', () => { }, ); - expect(order).toEqual(['responses', 'messages', 'chat']); + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + + it('derives responses-only candidates for compact requests before surface fallback logic', async () => { + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + undefined, + undefined, + { + requestKind: 'responses-compact', + }, + ); + + expect(order).toEqual(['responses']); + }); + + it('derives responses-only candidates when the request requires native responses file-url handling', async () => { + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + undefined, + { + hasNonImageFileInput: true, + }, + { + requiresNativeResponsesFileUrl: true, + }, + ); + + expect(order).toEqual(['responses']); + }); + + it('derives responses-first candidates for codex oauth hints without surface-local reordering', async () => { + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + undefined, + undefined, + { + oauthProvider: 'codex', + }, + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); }); it('prefers document-capable endpoints when downstream content contains non-image files', async () => { @@ -547,7 +615,63 @@ describe('resolveUpstreamEndpointCandidates', () => { expect(order).toEqual(['responses', 'messages']); }); - it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => { + it('learns to prefer /v1/messages after a chat endpoint says messages is required', async () => { + const memoryWrite = recordUpstreamEndpointFailure({ + siteId: baseContext.site.id, + endpoint: 'chat', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + status: 400, + errorText: '{"error":{"message":"messages is required","type":"upstream_error"}}', + }); + expect(memoryWrite).toMatchObject({ + action: 'failure', + endpoint: 'chat', + blockedEndpoint: 'chat', + preferredEndpoint: 'messages', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + ); + + expect(order).toEqual(['messages', 'responses']); + }); + + it('learns to prefer /v1/responses after a non-responses endpoint says input is required', async () => { + const memoryWrite = recordUpstreamEndpointFailure({ + siteId: baseContext.site.id, + endpoint: 'chat', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + status: 400, + errorText: '{"error":{"message":"input is required","type":"invalid_request_error"}}', + }); + expect(memoryWrite).toMatchObject({ + action: 'failure', + endpoint: 'chat', + blockedEndpoint: 'chat', + preferredEndpoint: 'responses', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + ); + + expect(order).toEqual(['responses', 'messages']); + }); + + it('keeps openai platform responses-first even when the catalog only advertises generic openai/chat support', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ { @@ -567,7 +691,7 @@ describe('resolveUpstreamEndpointCandidates', () => { 'openai', ); - expect(order).toEqual(['messages', 'chat', 'responses']); + expect(order).toEqual(['responses', 'chat', 'messages']); }); it('keeps anyrouter messages-first special case', async () => { @@ -623,7 +747,8 @@ describe('resolveUpstreamEndpointCandidates', () => { expect(isEndpointDowngradeError(404, '{"error":{"message":"Not Found","type":"not_found_error"}}')).toBe(true); expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true); expect(isEndpointDowngradeError(400, '{"error":{"message":"unsupported endpoint","type":"invalid_request_error"}}')).toBe(true); - expect(isEndpointDowngradeError(400, '{"error":{"message":"","type":"upstream_error"}}')).toBe(true); + expect(isEndpointDowngradeError(400, '{"error":{"message":"","type":"upstream_error"}}')).toBe(false); + expect(isEndpointDowngradeError(400, '{"error":{"message":"upstream_error: unsupported endpoint /v1/responses","type":"upstream_error"}}')).toBe(true); expect(isEndpointDowngradeError(400, '{"error":{"message":"openai_error","type":"bad_response_status_code"}}')).toBe(true); expect(isEndpointDowngradeError(415, '{"error":{"message":"Unsupported Media Type: Only \\"application/json\\" is allowed"}}')).toBe(true); }); @@ -817,7 +942,7 @@ describe('buildUpstreamEndpointRequest', () => { expect(request.headers['User-Agent']).toBe('codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'); expect(request.headers.Accept).toBe('application/json'); expect(request.headers.Connection).toBe('Keep-Alive'); - expect(request.body.instructions).toBe(''); + expect(request.body.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS); expect(request.body.prompt_cache_key).toBeUndefined(); expect(request.body.stream).toBe(false); expect(request.body.store).toBe(false); @@ -956,7 +1081,7 @@ describe('buildUpstreamEndpointRequest', () => { expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); expect(request.headers.Conversation_id).toBe(request.headers.Session_id); expect(request.body.prompt_cache_key).toBeUndefined(); - expect(request.body.instructions).toBe(''); + expect(request.body.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS); expect(request.body.stream).toBe(false); expect(request.body.store).toBe(false); expect(request.body.parallel_tool_calls).toBe(false); @@ -1300,7 +1425,7 @@ describe('buildUpstreamEndpointRequest', () => { expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); }); - it('converts system roles to developer in native codex responses bodies', () => { + it('extracts system input into top-level instructions in native codex responses bodies', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'responses', modelName: 'gpt-5.4', @@ -1312,6 +1437,7 @@ describe('buildUpstreamEndpointRequest', () => { downstreamFormat: 'responses', responsesOriginalBody: { model: 'gpt-5.4', + instructions: 'keep edits narrow', input: [ { type: 'message', @@ -1328,17 +1454,13 @@ describe('buildUpstreamEndpointRequest', () => { }); expect(request.body.input).toEqual([ - { - type: 'message', - role: 'developer', - content: [{ type: 'input_text', text: 'be careful' }], - }, { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }], }, ]); + expect(request.body.instructions).toBe('be careful\n\nkeep edits narrow'); }); it('normalizes downstream responses input string before forwarding upstream', () => { diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 18ca9ed..aab01fb 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -1,30 +1,3 @@ -import { randomUUID } from 'node:crypto'; -import { - rankConversationFileEndpoints, - type ConversationFileInputSummary, -} from '../../proxy-core/capabilities/conversationFileCapabilities.js'; -import type { UpstreamEndpoint } from '../../proxy-core/orchestration/upstreamRequest.js'; -import { resolveProviderProfile } from '../../proxy-core/providers/registry.js'; -import { config } from '../../config.js'; -import { fetchModelPricingCatalog } from '../../services/modelPricingService.js'; -import { applyPayloadRules } from '../../services/payloadRules.js'; -import { - applyUpstreamEndpointRuntimePreference, - buildEndpointCapabilityProfile, -} from '../../services/upstreamEndpointRuntimeMemory.js'; -import type { DownstreamFormat } from '../../transformers/shared/normalized.js'; -import { - convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer, - sanitizeResponsesBodyForProxy as sanitizeResponsesBodyForProxyViaTransformer, -} from '../../transformers/openai/responses/conversion.js'; -import { normalizeCodexResponsesBodyForProxy } from '../../transformers/openai/responses/codexCompatibility.js'; -import { - convertOpenAiBodyToAnthropicMessagesBody, - sanitizeAnthropicMessagesBody, -} from '../../transformers/anthropic/messages/conversion.js'; -import { - buildGeminiGenerateContentRequestFromOpenAi, -} from './geminiCliCompat.js'; import { buildMinimalJsonHeadersForCompatibility, isEndpointDispatchDeniedError, @@ -33,12 +6,7 @@ import { promoteResponsesCandidateAfterLegacyChatError, shouldPreferResponsesAfterLegacyChatError, } from '../../transformers/shared/endpointCompatibility.js'; -import { - buildClaudeRuntimeHeaders, - buildGeminiCliUserAgent, - getInputHeader, - headerValueToString, -} from '../../proxy-core/providers/headerUtils.js'; + export { buildMinimalJsonHeadersForCompatibility, isEndpointDispatchDeniedError, @@ -49,1053 +17,12 @@ export { }; export type { UpstreamEndpoint } from '../../proxy-core/orchestration/upstreamRequest.js'; -export type EndpointPreference = DownstreamFormat | 'responses'; - -type ChannelContext = { - site: { - id: number; - url: string; - platform: string; - apiKey?: string | null; - }; - account: { - id: number; - accessToken?: string | null; - apiToken?: string | null; - }; -}; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - -function asTrimmedString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function resolveRequestedModelForPayloadRules(input: { - modelName: string; - openaiBody: Record; - claudeOriginalBody?: Record; - responsesOriginalBody?: Record; -}): string { - return ( - asTrimmedString(input.responsesOriginalBody?.model) - || asTrimmedString(input.claudeOriginalBody?.model) - || asTrimmedString(input.openaiBody.model) - || asTrimmedString(input.modelName) - ); -} - -function normalizePlatformName(platform: unknown): string { - return asTrimmedString(platform).toLowerCase(); -} - -const HOP_BY_HOP_HEADERS = new Set([ - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', -]); - -const BLOCKED_PASSTHROUGH_HEADERS = new Set([ - 'host', - 'content-type', - 'content-length', - 'accept-encoding', - 'cookie', - 'authorization', - 'x-api-key', - 'x-goog-api-key', - 'sec-websocket-key', - 'sec-websocket-version', - 'sec-websocket-extensions', -]); -const GENERIC_PASSTHROUGH_ALLOWED_HEADERS = new Set([ - 'accept', - 'accept-language', - 'conversation-id', - 'conversation_id', - 'openai-beta', - 'originator', - 'session-id', - 'session_id', - 'user-agent', - 'x-codex-beta-features', - 'x-codex-turn-metadata', - 'x-codex-turn-state', -]); -const METAPI_INTERNAL_HEADER_BLOCKLIST = new Set([ - 'x-metapi-tester-request', - 'x-metapi-tester-forced-channel-id', - 'x-metapi-responses-websocket-mode', - 'x-metapi-responses-websocket-transport', -]); - -const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; - -function shouldSkipPassthroughHeader(key: string): boolean { - if (HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key)) return true; - if (METAPI_INTERNAL_HEADER_BLOCKLIST.has(key)) return true; - if (key.startsWith('x-metapi-')) return true; - if (!GENERIC_PASSTHROUGH_ALLOWED_HEADERS.has(key)) return true; - return false; -} - -function extractSafePassthroughHeaders( - headers?: Record, -): Record { - if (!headers) return {}; - - const forwarded: Record = {}; - for (const [rawKey, rawValue] of Object.entries(headers)) { - const key = rawKey.toLowerCase(); - if (!key || shouldSkipPassthroughHeader(key)) continue; - - const value = headerValueToString(rawValue); - if (!value) continue; - forwarded[key] = value; - } - - return forwarded; -} - -function extractClaudePassthroughHeaders( - headers?: Record, -): Record { - if (!headers) return {}; - - const forwarded: Record = {}; - for (const [rawKey, rawValue] of Object.entries(headers)) { - const key = rawKey.toLowerCase(); - const shouldForward = ( - key.startsWith('anthropic-') - || key.startsWith('x-claude-') - || key.startsWith('x-stainless-') - ); - if (!shouldForward) continue; - - const value = headerValueToString(rawValue); - if (!value) continue; - forwarded[key] = value; - } - - return forwarded; -} - -function extractResponsesPassthroughHeaders( - headers?: Record, -): Record { - if (!headers) return {}; - - const forwarded: Record = {}; - for (const [rawKey, rawValue] of Object.entries(headers)) { - const key = rawKey.toLowerCase(); - const shouldForward = ( - key.startsWith('openai-') - || key.startsWith('x-openai-') - || key.startsWith('x-stainless-') - || key.startsWith('chatgpt-') - || key === 'originator' - ); - if (!shouldForward) continue; - - const value = headerValueToString(rawValue); - if (!value) continue; - forwarded[key] = value; - } - - return forwarded; -} - -function extractCodexPassthroughHeaders( - headers?: Record, -): Record { - if (!headers) return {}; - - const forwarded: Record = {}; - for (const [rawKey, rawValue] of Object.entries(headers)) { - const key = rawKey.toLowerCase(); - const shouldForward = ( - key === 'version' - || key === 'x-responsesapi-include-timing-metrics' - ); - if (!shouldForward) continue; - - const value = headerValueToString(rawValue); - if (!value) continue; - forwarded[key] = value; - } - - return forwarded; -} - -function extractClaudeBetasFromBody(body: Record): { - body: Record; - betas: string[]; -} { - const next = { ...body }; - const rawBetas = next.betas; - delete next.betas; - - if (typeof rawBetas === 'string') { - return { - body: next, - betas: rawBetas.split(',').map((entry) => entry.trim()).filter(Boolean), - }; - } - - if (Array.isArray(rawBetas)) { - return { - body: next, - betas: rawBetas - .map((entry) => asTrimmedString(entry)) - .filter(Boolean), - }; - } - - return { - body: next, - betas: [], - }; -} - -function stripClaudeMessagesContinuationFields( - body: Record, -): Record { - const next = { ...body }; - delete next.previous_response_id; - delete next.prompt_cache_key; - return next; -} - -function buildAntigravityRuntimeHeaders(input: { - baseHeaders: Record; - stream: boolean; -}): Record { - const headers: Record = { - Authorization: input.baseHeaders.Authorization, - 'Content-Type': 'application/json', - Accept: input.stream ? 'text/event-stream' : 'application/json', - 'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT, - }; - return headers; -} - -function ensureStreamAcceptHeader( - headers: Record, - stream: boolean, -): Record { - if (!stream) return headers; - - const existingAccept = ( - headerValueToString(headers.accept) - || headerValueToString((headers as Record).Accept) - ); - if (existingAccept) return headers; - - return { - ...headers, - accept: 'text/event-stream', - }; -} - -function normalizeResponsesFallbackChatFunctionTool(rawTool: unknown): Record | null { - if (!isRecord(rawTool)) return null; - if (asTrimmedString(rawTool.type).toLowerCase() !== 'function') return null; - - if (isRecord(rawTool.function)) { - const name = asTrimmedString(rawTool.function.name); - if (!name) return null; - return { - ...rawTool, - type: 'function', - function: { - ...rawTool.function, - name, - }, - }; - } - - const name = asTrimmedString(rawTool.name); - if (!name) return null; - - const fn: Record = { name }; - const description = asTrimmedString(rawTool.description); - if (description) fn.description = description; - if (rawTool.parameters !== undefined) fn.parameters = rawTool.parameters; - if (rawTool.strict !== undefined) fn.strict = rawTool.strict; - - return { - type: 'function', - function: fn, - }; -} - -function normalizeResponsesFallbackChatToolChoice( - rawToolChoice: unknown, - allowedToolNames: Set, -): unknown { - if (rawToolChoice === undefined) return undefined; - - if (typeof rawToolChoice === 'string') { - const normalized = rawToolChoice.trim().toLowerCase(); - if (normalized === 'none') return 'none'; - if (allowedToolNames.size <= 0) return undefined; - if (normalized === 'auto' || normalized === 'required') return normalized; - return undefined; - } - - if (!isRecord(rawToolChoice)) return undefined; - if (asTrimmedString(rawToolChoice.type).toLowerCase() !== 'function') return undefined; - - const nestedFunction = isRecord(rawToolChoice.function) ? rawToolChoice.function : null; - const name = asTrimmedString(nestedFunction?.name ?? rawToolChoice.name); - if (!name || !allowedToolNames.has(name)) return undefined; - - return { - type: 'function', - function: { - ...(nestedFunction || {}), - name, - }, - }; -} - -function sanitizeResponsesFallbackChatBody( - body: Record, -): Record { - const next: Record = { ...body }; - const normalizedTools = Array.isArray(body.tools) - ? body.tools - .map((tool) => normalizeResponsesFallbackChatFunctionTool(tool)) - .filter((tool): tool is Record => !!tool) - : []; - - if (normalizedTools.length > 0) { - next.tools = normalizedTools; - } else { - delete next.tools; - } - - const allowedToolNames = new Set( - normalizedTools - .map((tool) => ( - isRecord(tool.function) - ? asTrimmedString(tool.function.name) - : '' - )) - .filter((name) => name.length > 0), - ); - const normalizedToolChoice = normalizeResponsesFallbackChatToolChoice( - body.tool_choice, - allowedToolNames, - ); - if (normalizedToolChoice !== undefined) { - next.tool_choice = normalizedToolChoice; - } else { - delete next.tool_choice; - } - - return next; -} - -function toFiniteNumber(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null; -} - -function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] { - const raw = asTrimmedString(value).toLowerCase(); - if (!raw) return []; - - const normalized = new Set(); - - if ( - raw.includes('/v1/messages') - || raw === 'messages' - || raw.includes('anthropic') - || raw.includes('claude') - ) { - normalized.add('messages'); - } - - if ( - raw.includes('/v1/responses') - || raw === 'responses' - || raw.includes('response') - ) { - normalized.add('responses'); - } - - if ( - raw.includes('/v1/chat/completions') - || raw.includes('chat/completions') - || raw === 'chat' - || raw === 'chat_completions' - || raw === 'completions' - || raw.includes('chat') - ) { - normalized.add('chat'); - } - - // Some upstreams return protocol families instead of concrete endpoint paths. - if (raw === 'openai' || raw.includes('openai')) { - normalized.add('chat'); - normalized.add('responses'); - } - - return Array.from(normalized); -} - -function preferredEndpointOrder( - downstreamFormat: EndpointPreference, - sitePlatform?: string, - preferMessagesForClaudeModel = false, -): UpstreamEndpoint[] { - const platform = normalizePlatformName(sitePlatform); - - if (platform === 'codex') { - return ['responses']; - } - - if (platform === 'gemini') { - // Gemini upstream is routed through OpenAI-compatible chat endpoint. - return ['chat']; - } - - if (platform === 'gemini-cli') { - return ['chat']; - } - - if (platform === 'antigravity') { - return ['chat']; - } - - if (platform === 'openai') { - if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') { - // Some OpenAI-compatible gateways expose Claude natively via /v1/messages. - // Keep chat/responses as fallbacks when messages is unavailable. - return ['messages', 'chat', 'responses']; - } - return downstreamFormat === 'responses' - ? ['responses', 'chat', 'messages'] - : ['chat', 'responses', 'messages']; - } - - if (platform === 'claude') { - return ['messages']; - } - - // Unknown/generic upstreams: prefer endpoint family that matches the - // downstream API surface, then degrade progressively. - if (downstreamFormat === 'responses') { - if (preferMessagesForClaudeModel) { - // Claude-family models on generic/new-api upstreams are commonly - // messages-first even when downstream API is /v1/responses. - return ['messages', 'chat', 'responses']; - } - return ['responses', 'chat', 'messages']; - } - - if (downstreamFormat === 'claude') { - return ['messages', 'chat', 'responses']; - } - - if (downstreamFormat === 'openai' && preferMessagesForClaudeModel) { - // Claude-family models are most stable with native Messages semantics. - return ['messages', 'chat', 'responses']; - } - - return ['chat', 'messages', 'responses']; -} - -export async function resolveUpstreamEndpointCandidates( - context: ChannelContext, - modelName: string, - downstreamFormat: EndpointPreference, - requestedModelHint?: string, - requestCapabilities?: { - hasNonImageFileInput?: boolean; - conversationFileSummary?: ConversationFileInputSummary; - wantsNativeResponsesReasoning?: boolean; - wantsContinuationAwareResponses?: boolean; - }, -): Promise { - const sitePlatform = normalizePlatformName(context.site.platform); - const capabilityProfile = buildEndpointCapabilityProfile({ - modelName, - requestedModelHint, - requestCapabilities, - }); - const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel; - const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput; - const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning; - const wantsContinuationAwareResponses = capabilityProfile.wantsContinuationAwareResponses; - const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => ( - applyUpstreamEndpointRuntimePreference(candidates, { - siteId: context.site.id, - downstreamFormat, - capabilityProfile, - }) - ); - const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? { - hasImage: false, - hasAudio: false, - hasDocument: hasNonImageFileInput, - hasRemoteDocumentUrl: false, - }; - if (sitePlatform === 'anyrouter') { - // anyrouter deployments are effectively anthropic-protocol first. - if (hasNonImageFileInput) { - return applyRuntimePreference(downstreamFormat === 'responses' - ? ['responses', 'messages', 'chat'] - : ['messages', 'responses', 'chat']); - } - if (downstreamFormat === 'responses') { - return applyRuntimePreference(['responses', 'messages', 'chat']); - } - return applyRuntimePreference(['messages', 'chat', 'responses']); - } - - const preferred = preferredEndpointOrder( - downstreamFormat, - context.site.platform, - preferMessagesForClaudeModel, - ); - const preferredWithCapabilities = hasNonImageFileInput - ? (() => { - if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[]; - if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[]; - if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[]; - return rankConversationFileEndpoints({ - sitePlatform, - requestedOrder: preferMessagesForClaudeModel - ? ['messages', 'responses', 'chat'] - : ['responses', 'messages', 'chat'], - summary: conversationFileSummary, - preferMessagesForClaudeModel, - }); - })() - : preferred; - const prioritizedPreferredEndpoints: UpstreamEndpoint[] = ( - preferredWithCapabilities.includes('responses') - && ( - wantsContinuationAwareResponses - || (wantsNativeResponsesReasoning && preferMessagesForClaudeModel) - ) - ) - ? [ - 'responses', - ...preferredWithCapabilities.filter((endpoint): endpoint is UpstreamEndpoint => endpoint !== 'responses'), - ] - : preferredWithCapabilities; - const forceMessagesFirstForClaudeModel = ( - downstreamFormat === 'openai' - && preferMessagesForClaudeModel - && sitePlatform !== 'openai' - && sitePlatform !== 'gemini' - && sitePlatform !== 'antigravity' - && sitePlatform !== 'gemini-cli' - ); - - try { - const catalog = await fetchModelPricingCatalog({ - site: { - id: context.site.id, - url: context.site.url, - platform: context.site.platform, - }, - account: { - id: context.account.id, - accessToken: context.account.accessToken ?? null, - apiToken: context.account.apiToken ?? null, - }, - modelName, - totalTokens: 0, - }); - - if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) { - return applyRuntimePreference(prioritizedPreferredEndpoints); - } - - const matched = catalog.models.find((item) => - asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(), - ); - if (!matched) return applyRuntimePreference(prioritizedPreferredEndpoints); - - const shouldIgnoreCatalogOrderingForClaudeMessages = ( - preferMessagesForClaudeModel - && (downstreamFormat !== 'responses' || sitePlatform !== 'openai') - ); - if (shouldIgnoreCatalogOrderingForClaudeMessages) { - return applyRuntimePreference(prioritizedPreferredEndpoints); - } - - const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : []; - const normalizedSupportedRaw = supportedRaw - .map((item) => asTrimmedString(item).toLowerCase()) - .filter((item) => item.length > 0); - const hasConcreteEndpointHint = normalizedSupportedRaw.some((raw) => ( - raw.includes('/v1/messages') - || raw.includes('/v1/chat/completions') - || raw.includes('/v1/responses') - || raw === 'messages' - || raw === 'chat' - || raw === 'chat_completions' - || raw === 'completions' - || raw === 'responses' - )); - if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) { - // Generic labels like openai/anthropic are too coarse for Claude models; - // keep messages-first order in this case. - return applyRuntimePreference(prioritizedPreferredEndpoints); - } - - const supported = new Set(); - for (const endpoint of supportedRaw) { - const normalizedList = normalizeEndpointTypes(endpoint); - for (const normalized of normalizedList) { - supported.add(normalized); - } - } - - if (supported.size === 0) return applyRuntimePreference(prioritizedPreferredEndpoints); - - const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint)); - if (!firstSupported) return applyRuntimePreference(prioritizedPreferredEndpoints); - - // Catalog metadata can be incomplete/inaccurate, so only use it to pick - // the first attempt. Keep downstream-driven fallback order unchanged. - return applyRuntimePreference([ - firstSupported, - ...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported), - ]); - } catch { - return applyRuntimePreference(prioritizedPreferredEndpoints); - } -} - -export function buildUpstreamEndpointRequest(input: { - endpoint: UpstreamEndpoint; - modelName: string; - stream: boolean; - tokenValue: string; - oauthProvider?: string; - oauthProjectId?: string; - sitePlatform?: string; - siteUrl?: string; - openaiBody: Record; - downstreamFormat: EndpointPreference; - claudeOriginalBody?: Record; - forceNormalizeClaudeBody?: boolean; - responsesOriginalBody?: Record; - downstreamHeaders?: Record; - providerHeaders?: Record; - codexSessionCacheKey?: string | null; - codexExplicitSessionId?: string | null; -}): { - path: string; - headers: Record; - body: Record; - runtime?: { - executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; - modelName?: string; - stream?: boolean; - oauthProjectId?: string | null; - action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; - }; -} { - const sitePlatform = normalizePlatformName(input.sitePlatform); - const providerProfile = resolveProviderProfile(sitePlatform); - const isClaudeUpstream = sitePlatform === 'claude'; - const isGeminiUpstream = sitePlatform === 'gemini'; - const isGeminiCliUpstream = sitePlatform === 'gemini-cli'; - const isAntigravityUpstream = sitePlatform === 'antigravity'; - const isInternalGeminiUpstream = isGeminiCliUpstream || isAntigravityUpstream; - const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude'; - - const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => { - const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase(); - const openAiCompatBase = /\/openai(?:\/|$)/.test(normalizedSiteUrl); - if (openAiCompatBase) { - return endpoint === 'responses' - ? '/responses' - : '/chat/completions'; - } - return endpoint === 'responses' - ? '/v1beta/openai/responses' - : '/v1beta/openai/chat/completions'; - }; - - const resolveEndpointPath = (endpoint: UpstreamEndpoint): string => { - if (isGeminiUpstream) { - return resolveGeminiEndpointPath(endpoint); - } - - if (sitePlatform === 'openai') { - if (endpoint === 'messages') return '/v1/messages'; - if (endpoint === 'responses') return '/v1/responses'; - return '/v1/chat/completions'; - } - - if (sitePlatform === 'codex') { - return '/responses'; - } - - if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') { - return input.stream - ? '/v1internal:streamGenerateContent?alt=sse' - : '/v1internal:generateContent'; - } - - if (sitePlatform === 'claude') { - return '/v1/messages'; - } - - if (endpoint === 'messages') return '/v1/messages'; - if (endpoint === 'responses') return '/v1/responses'; - return '/v1/chat/completions'; - }; - - const passthroughHeaders = extractSafePassthroughHeaders(input.downstreamHeaders); - const codexPassthroughHeaders = sitePlatform === 'codex' - ? extractCodexPassthroughHeaders(input.downstreamHeaders) - : {}; - const commonHeaders: Record = { - ...passthroughHeaders, - ...codexPassthroughHeaders, - 'Content-Type': 'application/json', - ...(input.providerHeaders || {}), - }; - if (!isClaudeUpstream) { - commonHeaders.Authorization = `Bearer ${input.tokenValue}`; - } - - const stripGeminiUnsupportedFields = (body: Record) => { - const next = { ...body }; - if (isGeminiUpstream || isInternalGeminiUpstream) { - for (const key of [ - 'frequency_penalty', - 'presence_penalty', - 'logit_bias', - 'logprobs', - 'top_logprobs', - 'store', - ]) { - delete next[key]; - } - } - return next; - }; - - const openaiBody = stripGeminiUnsupportedFields(input.openaiBody); - const runtime = { - executor: ( - sitePlatform === 'codex' - ? 'codex' - : sitePlatform === 'gemini-cli' - ? 'gemini-cli' - : sitePlatform === 'antigravity' - ? 'antigravity' - : sitePlatform === 'claude' - ? 'claude' - : 'default' - ) as 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude', - modelName: input.modelName, - stream: input.stream, - oauthProjectId: asTrimmedString(input.oauthProjectId) || null, - }; - const requestedModelForPayloadRules = resolveRequestedModelForPayloadRules(input); - const applyConfiguredPayloadRules = >(body: T): T => ( - applyPayloadRules({ - rules: config.payloadRules, - payload: body, - modelName: input.modelName, - requestedModel: requestedModelForPayloadRules, - protocol: sitePlatform, - }) as T - ); - - if (isInternalGeminiUpstream) { - const instructions = ( - input.downstreamFormat === 'responses' - && typeof input.responsesOriginalBody?.instructions === 'string' - ) - ? input.responsesOriginalBody.instructions - : undefined; - const geminiRequest = buildGeminiGenerateContentRequestFromOpenAi({ - body: openaiBody, - modelName: input.modelName, - instructions, - }); - const configuredGeminiRequest = applyConfiguredPayloadRules(geminiRequest); - if (!providerProfile) { - throw new Error(`missing provider profile for platform: ${sitePlatform}`); - } - return providerProfile.prepareRequest({ - endpoint: input.endpoint, - modelName: input.modelName, - stream: input.stream, - tokenValue: input.tokenValue, - oauthProvider: input.oauthProvider, - oauthProjectId: input.oauthProjectId, - sitePlatform, - baseHeaders: commonHeaders, - providerHeaders: input.providerHeaders, - body: configuredGeminiRequest, - action: input.stream ? 'streamGenerateContent' : 'generateContent', - }); - } - - if (input.endpoint === 'messages') { - const claudeHeaders = input.downstreamFormat === 'claude' - ? extractClaudePassthroughHeaders(input.downstreamHeaders) - : {}; - const anthropicVersion = ( - claudeHeaders['anthropic-version'] - || passthroughHeaders['anthropic-version'] - || '2023-06-01' - ); - const nativeClaudeBody = ( - input.downstreamFormat === 'claude' - && input.claudeOriginalBody - && input.forceNormalizeClaudeBody !== true - ) - ? { - ...stripClaudeMessagesContinuationFields(input.claudeOriginalBody), - model: input.modelName, - stream: input.stream, - } - : null; - const normalizedClaudeBody = ( - input.downstreamFormat === 'claude' - && input.claudeOriginalBody - && input.forceNormalizeClaudeBody === true - ) - ? sanitizeAnthropicMessagesBody({ - ...stripClaudeMessagesContinuationFields(input.claudeOriginalBody), - model: input.modelName, - stream: input.stream, - }) - : null; - const sanitizedBody = nativeClaudeBody - ?? normalizedClaudeBody - ?? sanitizeAnthropicMessagesBody( - convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream), - ); - const configuredClaudeBody = applyConfiguredPayloadRules(sanitizedBody); - - if (providerProfile?.id === 'claude') { - return providerProfile.prepareRequest({ - endpoint: 'messages', - modelName: input.modelName, - stream: input.stream, - tokenValue: input.tokenValue, - oauthProvider: input.oauthProvider, - oauthProjectId: input.oauthProjectId, - sitePlatform, - baseHeaders: commonHeaders, - claudeHeaders, - body: configuredClaudeBody, - }); - } - - const headers = buildClaudeRuntimeHeaders({ - baseHeaders: commonHeaders, - claudeHeaders, - anthropicVersion, - stream: input.stream, - isClaudeOauthUpstream, - tokenValue: input.tokenValue, - }); - - return { - path: resolveEndpointPath('messages'), - headers, - body: configuredClaudeBody, - runtime, - }; - } - - if (input.endpoint === 'responses') { - const responsesWebsocketTransport = getInputHeader( - input.downstreamHeaders, - 'x-metapi-responses-websocket-transport', - ) === '1'; - const websocketMode = Object.entries(input.downstreamHeaders || {}).find(([rawKey]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-mode'); - const preserveWebsocketIncrementalMode = asTrimmedString(websocketMode?.[1]).toLowerCase() === 'incremental'; - const responsesHeaders = input.downstreamFormat === 'responses' - ? extractResponsesPassthroughHeaders(input.downstreamHeaders) - : {}; - const rawBody = ( - input.downstreamFormat === 'responses' && input.responsesOriginalBody - ? { - ...stripGeminiUnsupportedFields(input.responsesOriginalBody), - model: input.modelName, - stream: input.stream, - } - : convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream) - ); - const sanitizedResponsesBody = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); - if (preserveWebsocketIncrementalMode && rawBody.generate === false) { - sanitizedResponsesBody.generate = false; - } - const body = normalizeCodexResponsesBodyForProxy( - sanitizedResponsesBody, - sitePlatform, - ); - const configuredResponsesBody = normalizeCodexResponsesBodyForProxy( - applyConfiguredPayloadRules(body), - sitePlatform, - ); - - if (sitePlatform === 'codex') { - if (providerProfile?.id !== 'codex') { - throw new Error(`missing codex provider profile for platform: ${sitePlatform}`); - } - return providerProfile.prepareRequest({ - endpoint: 'responses', - modelName: input.modelName, - stream: input.stream, - tokenValue: input.tokenValue, - oauthProvider: input.oauthProvider, - oauthProjectId: input.oauthProjectId, - sitePlatform, - baseHeaders: { - ...commonHeaders, - ...responsesHeaders, - }, - providerHeaders: input.providerHeaders, - codexSessionCacheKey: input.codexSessionCacheKey, - codexExplicitSessionId: input.codexExplicitSessionId, - responsesWebsocketTransport, - body: configuredResponsesBody, - }); - } - - const headers = ensureStreamAcceptHeader({ - ...commonHeaders, - ...responsesHeaders, - }, input.stream); - return { - path: resolveEndpointPath('responses'), - headers, - body: configuredResponsesBody, - runtime, - }; - } - - const headers = ensureStreamAcceptHeader(commonHeaders, input.stream); - const chatBody = { - ...openaiBody, - model: input.modelName, - stream: input.stream, - }; - const configuredChatBody = applyConfiguredPayloadRules( - input.downstreamFormat === 'responses' - ? sanitizeResponsesFallbackChatBody(chatBody) - : chatBody, - ); - return { - path: resolveEndpointPath('chat'), - headers, - body: configuredChatBody, - runtime, - }; -} - -export function buildClaudeCountTokensUpstreamRequest(input: { - modelName: string; - tokenValue: string; - oauthProvider?: string; - sitePlatform?: string; - claudeBody: Record; - downstreamHeaders?: Record; -}): { - path: string; - headers: Record; - body: Record; - runtime: { - executor: 'claude'; - modelName: string; - stream: false; - action: 'countTokens'; - }; -} { - const sitePlatform = normalizePlatformName(input.sitePlatform); - const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders); - const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({ - ...stripClaudeMessagesContinuationFields(input.claudeBody), - model: input.modelName, - }); - const sanitizedBody = sanitizeAnthropicMessagesBody(bodyWithoutBetas); - delete sanitizedBody.max_tokens; - delete sanitizedBody.maxTokens; - delete sanitizedBody.stream; - const providerProfile = resolveProviderProfile(sitePlatform); - const effectiveClaudeHeaders = { - ...claudeHeaders, - ...(betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}), - }; - - if (providerProfile?.id === 'claude') { - const prepared = providerProfile.prepareRequest({ - endpoint: 'messages', - modelName: input.modelName, - stream: false, - tokenValue: input.tokenValue, - oauthProvider: input.oauthProvider, - sitePlatform, - baseHeaders: { - 'Content-Type': 'application/json', - }, - claudeHeaders: effectiveClaudeHeaders, - body: sanitizedBody, - action: 'countTokens', - }); - - return { - path: prepared.path, - headers: prepared.headers, - body: prepared.body, - runtime: { - executor: 'claude', - modelName: input.modelName, - stream: false, - action: 'countTokens', - }, - }; - } - - const anthropicVersion = ( - effectiveClaudeHeaders['anthropic-version'] - || '2023-06-01' - ); - const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude'; - const headers = buildClaudeRuntimeHeaders({ - baseHeaders: { - 'Content-Type': 'application/json', - }, - claudeHeaders: effectiveClaudeHeaders, - anthropicVersion, - stream: false, - isClaudeOauthUpstream, - tokenValue: input.tokenValue, - }); - - return { - path: '/v1/messages/count_tokens?beta=true', - headers, - body: sanitizedBody, - runtime: { - executor: 'claude', - modelName: input.modelName, - stream: false, - action: 'countTokens', - }, - }; -} +export { + buildClaudeCountTokensUpstreamRequest, + buildUpstreamEndpointRequest, +} from '../../services/upstreamRequestBuilder.js'; +export { resolveUpstreamEndpointCandidates } from '../../services/upstreamEndpointDerivation.js'; +export type { + EndpointDerivationHints, + EndpointPreference, +} from '../../services/upstreamEndpointDerivation.js'; diff --git a/src/server/services/oauth/codexProvider.ts b/src/server/services/oauth/codexProvider.ts index a135044..9ac5ed7 100644 --- a/src/server/services/oauth/codexProvider.ts +++ b/src/server/services/oauth/codexProvider.ts @@ -1,5 +1,6 @@ import { fetch } from 'undici'; import { config } from '../../config.js'; +import { inferCodexOfficialOriginator } from '../../shared/codexClientFamily.js'; import { withExplicitProxyRequestInit } from '../siteProxy.js'; import { createPkceChallenge } from './sessionStore.js'; import type { OAuthProviderDefinition } from './providers.js'; @@ -234,7 +235,9 @@ export const codexOauthProvider: OAuthProviderDefinition = { }, buildProxyHeaders: ({ oauth, downstreamHeaders }) => { const accountId = oauth.accountId || oauth.accountKey; - const originator = getHeaderValue(downstreamHeaders, 'originator') || 'codex_cli_rs'; + const originator = inferCodexOfficialOriginator(downstreamHeaders) + || getHeaderValue(downstreamHeaders, 'originator') + || 'codex_cli_rs'; const headers: Record = { Originator: originator, }; diff --git a/src/server/services/payloadRules.test.ts b/src/server/services/payloadRules.test.ts new file mode 100644 index 0000000..4e61c75 --- /dev/null +++ b/src/server/services/payloadRules.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { createEmptyPayloadRulesConfig, parsePayloadRulesConfigInput } from './payloadRules.js'; + +describe('parsePayloadRulesConfigInput', () => { + it('accepts CPA-style dashed raw keys and normalizes them to camelCase sections', () => { + const result = parsePayloadRulesConfigInput({ + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + 'override-raw': [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{"type":"json_schema"}', + }, + }, + ], + filter: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: ['safety_identifier'], + }, + ], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.normalized).toEqual({ + ...createEmptyPayloadRulesConfig(), + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + overrideRaw: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{"type":"json_schema"}', + }, + }, + ], + filter: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: ['safety_identifier'], + }, + ], + }); + }); + + it('rejects invalid raw JSON fragments', () => { + const result = parsePayloadRulesConfigInput({ + 'override-raw': [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{invalid-json', + }, + }, + ], + }); + + expect(result).toEqual({ + success: false, + message: 'Payload 规则 override-raw 第 1 条的 response_format 不是合法 JSON', + }); + }); + + it('rejects unknown sections so the settings UI cannot silently save ignored data', () => { + const result = parsePayloadRulesConfigInput({ + override: [], + unexpected: [], + }); + + expect(result).toEqual({ + success: false, + message: 'Payload 规则包含未知分组:unexpected', + }); + }); +}); diff --git a/src/server/services/payloadRules.ts b/src/server/services/payloadRules.ts index 76f7bcb..8f5a39e 100644 --- a/src/server/services/payloadRules.ts +++ b/src/server/services/payloadRules.ts @@ -23,6 +23,18 @@ export type PayloadRulesConfig = { filter: PayloadFilterRule[]; }; +type PayloadRulesParseSuccess = { + success: true; + normalized: PayloadRulesConfig; +}; + +type PayloadRulesParseFailure = { + success: false; + message: string; +}; + +export type PayloadRulesParseResult = PayloadRulesParseSuccess | PayloadRulesParseFailure; + function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } @@ -230,6 +242,165 @@ export function normalizePayloadRulesConfig(value: unknown): PayloadRulesConfig }; } +const PAYLOAD_RULE_SECTION_LABELS = { + default: 'default', + defaultRaw: 'default-raw', + override: 'override', + overrideRaw: 'override-raw', + filter: 'filter', +} as const; + +const PAYLOAD_RULE_ALLOWED_KEYS = new Set([ + 'default', + 'defaultRaw', + 'default-raw', + 'override', + 'overrideRaw', + 'override-raw', + 'filter', +]); + +function getPayloadRulesSectionValue(value: Record, key: keyof typeof PAYLOAD_RULE_SECTION_LABELS): unknown { + if (key === 'defaultRaw') return value.defaultRaw ?? value['default-raw']; + if (key === 'overrideRaw') return value.overrideRaw ?? value['override-raw']; + return value[key]; +} + +function hasConflictingPayloadRuleSectionKeys(value: Record, camelKey: 'defaultRaw' | 'overrideRaw', dashedKey: 'default-raw' | 'override-raw'): boolean { + return Object.prototype.hasOwnProperty.call(value, camelKey) && Object.prototype.hasOwnProperty.call(value, dashedKey); +} + +function validatePayloadRuleModelsInput(value: unknown, section: string, ruleIndex: number): string | null { + if (!Array.isArray(value) || value.length <= 0) { + return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models 必须是非空数组`; + } + + for (let modelIndex = 0; modelIndex < value.length; modelIndex += 1) { + const model = value[modelIndex]; + if (!isRecord(model)) { + return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}] 必须是对象`; + } + if (!asTrimmedString(model.name)) { + return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}].name 不能为空`; + } + if (model.protocol !== undefined && typeof model.protocol !== 'string') { + return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}].protocol 必须是字符串`; + } + } + + return null; +} + +function validatePayloadValueRuleSectionInput(value: unknown, section: string, raw: boolean): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) { + return `Payload 规则 ${section} 必须是数组`; + } + + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (!isRecord(item)) { + return `Payload 规则 ${section} 第 ${index + 1} 条必须是对象`; + } + + const modelsError = validatePayloadRuleModelsInput(item.models, section, index + 1); + if (modelsError) return modelsError; + + if (!isRecord(item.params) || Object.keys(item.params).length <= 0) { + return `Payload 规则 ${section} 第 ${index + 1} 条的 params 必须是非空对象`; + } + + for (const [path, rawValue] of Object.entries(item.params)) { + if (!asTrimmedString(path)) { + return `Payload 规则 ${section} 第 ${index + 1} 条包含空参数路径`; + } + if (!raw) continue; + if (typeof rawValue !== 'string') continue; + const trimmed = rawValue.trim(); + if (!trimmed) { + return `Payload 规则 ${section} 第 ${index + 1} 条的 ${path} 不能为空字符串`; + } + try { + JSON.parse(trimmed); + } catch { + return `Payload 规则 ${section} 第 ${index + 1} 条的 ${path} 不是合法 JSON`; + } + } + } + + return null; +} + +function validatePayloadFilterRuleSectionInput(value: unknown, section: string): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) { + return `Payload 规则 ${section} 必须是数组`; + } + + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (!isRecord(item)) { + return `Payload 规则 ${section} 第 ${index + 1} 条必须是对象`; + } + + const modelsError = validatePayloadRuleModelsInput(item.models, section, index + 1); + if (modelsError) return modelsError; + + if (!Array.isArray(item.params) || item.params.length <= 0) { + return `Payload 规则 ${section} 第 ${index + 1} 条的 params 必须是非空字符串数组`; + } + + for (let pathIndex = 0; pathIndex < item.params.length; pathIndex += 1) { + if (!asTrimmedString(item.params[pathIndex])) { + return `Payload 规则 ${section} 第 ${index + 1} 条的 params[${pathIndex + 1}] 不能为空`; + } + } + } + + return null; +} + +export function parsePayloadRulesConfigInput(value: unknown): PayloadRulesParseResult { + if (value == null) { + return { success: true, normalized: createEmptyPayloadRulesConfig() }; + } + if (!isRecord(value)) { + return { success: false, message: 'Payload 规则必须是 JSON 对象' }; + } + + if (hasConflictingPayloadRuleSectionKeys(value, 'defaultRaw', 'default-raw')) { + return { success: false, message: 'Payload 规则不能同时包含 defaultRaw 和 default-raw' }; + } + if (hasConflictingPayloadRuleSectionKeys(value, 'overrideRaw', 'override-raw')) { + return { success: false, message: 'Payload 规则不能同时包含 overrideRaw 和 override-raw' }; + } + + const unknownKeys = Object.keys(value).filter((key) => !PAYLOAD_RULE_ALLOWED_KEYS.has(key)); + if (unknownKeys.length > 0) { + return { + success: false, + message: `Payload 规则包含未知分组:${unknownKeys.join(', ')}`, + }; + } + + const valueSectionError = validatePayloadValueRuleSectionInput(value.default, PAYLOAD_RULE_SECTION_LABELS.default, false) + ?? validatePayloadValueRuleSectionInput(getPayloadRulesSectionValue(value, 'defaultRaw'), PAYLOAD_RULE_SECTION_LABELS.defaultRaw, true) + ?? validatePayloadValueRuleSectionInput(value.override, PAYLOAD_RULE_SECTION_LABELS.override, false) + ?? validatePayloadValueRuleSectionInput(getPayloadRulesSectionValue(value, 'overrideRaw'), PAYLOAD_RULE_SECTION_LABELS.overrideRaw, true) + ?? validatePayloadFilterRuleSectionInput(value.filter, PAYLOAD_RULE_SECTION_LABELS.filter); + if (valueSectionError) { + return { + success: false, + message: valueSectionError, + }; + } + + return { + success: true, + normalized: normalizePayloadRulesConfig(value), + }; +} + export function applyPayloadRules(input: { rules: PayloadRulesConfig; payload: Record; diff --git a/src/server/routes/proxy/proxyBilling.test.ts b/src/server/services/proxyBilling.test.ts similarity index 57% rename from src/server/routes/proxy/proxyBilling.test.ts rename to src/server/services/proxyBilling.test.ts index 5a8759f..0e9ce90 100644 --- a/src/server/routes/proxy/proxyBilling.test.ts +++ b/src/server/services/proxyBilling.test.ts @@ -1,8 +1,40 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +const estimateProxyCostMock = vi.fn(); +const buildProxyBillingDetailsMock = vi.fn(); + +vi.mock('./modelPricingService.js', () => ({ + estimateProxyCost: (...args: unknown[]) => estimateProxyCostMock(...args), + buildProxyBillingDetails: (...args: unknown[]) => buildProxyBillingDetailsMock(...args), +})); + import { resolveProxyLogBilling } from './proxyBilling.js'; describe('resolveProxyLogBilling', () => { it('uses self-log billing metadata for detail breakdown while preserving quota-derived total cost', async () => { + estimateProxyCostMock.mockResolvedValue(0.010001); + buildProxyBillingDetailsMock.mockResolvedValue({ + usage: { + cacheReadTokens: 145692, + cacheCreationTokens: 945, + billablePromptTokens: 1, + }, + pricing: { + modelRatio: 2.5, + completionRatio: 5, + cacheRatio: 0.1, + cacheCreationRatio: 1.25, + groupRatio: 1, + }, + breakdown: { + inputPerMillion: 5, + outputPerMillion: 25, + cacheReadPerMillion: 0.5, + cacheCreationPerMillion: 6.25, + totalCost: 0.083057, + }, + }); + const result = await resolveProxyLogBilling({ site: { id: 1, @@ -40,6 +72,18 @@ describe('resolveProxyLogBilling', () => { }, }); + expect(estimateProxyCostMock).toHaveBeenCalledWith(expect.objectContaining({ + cacheReadTokens: 145692, + cacheCreationTokens: 945, + promptTokensIncludeCache: true, + billingPricingOverride: { + modelRatio: 2.5, + completionRatio: 5, + cacheRatio: 0.1, + cacheCreationRatio: 1.25, + groupRatio: 1, + }, + })); expect(result.estimatedCost).toBe(0.083056); expect(result.billingDetails).toMatchObject({ usage: { diff --git a/src/server/services/proxyBilling.ts b/src/server/services/proxyBilling.ts new file mode 100644 index 0000000..8ad48d6 --- /dev/null +++ b/src/server/services/proxyBilling.ts @@ -0,0 +1,92 @@ +import { + buildProxyBillingDetails, + estimateProxyCost, + type ProxyBillingDetails, + type ProxyBillingPricingOverride, +} from './modelPricingService.js'; +import type { SelfLogBillingMeta } from './proxyUsageFallbackService.js'; + +interface ProxyBillingUsageSummary { + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + promptTokensIncludeCache: boolean | null; +} + +interface ResolvedProxyUsageSummary { + promptTokens: number; + completionTokens: number; + totalTokens: number; + recoveredFromSelfLog: boolean; + estimatedCostFromQuota: number; + selfLogBillingMeta: SelfLogBillingMeta | null; +} + +interface ResolveProxyLogBillingInput { + site: { + id: number; + url: string; + platform: string; + apiKey?: string | null; + }; + account: { + id: number; + accessToken?: string | null; + apiToken?: string | null; + }; + modelName: string; + parsedUsage: ProxyBillingUsageSummary; + resolvedUsage: ResolvedProxyUsageSummary; +} + +function toPricingOverride(meta: SelfLogBillingMeta | null): ProxyBillingPricingOverride | null { + if (!meta) return null; + return { + modelRatio: meta.modelRatio, + completionRatio: meta.completionRatio, + cacheRatio: meta.cacheRatio, + cacheCreationRatio: meta.cacheCreationRatio, + groupRatio: meta.groupRatio, + }; +} + +export async function resolveProxyLogBilling( + input: ResolveProxyLogBillingInput, +): Promise<{ estimatedCost: number; billingDetails: ProxyBillingDetails | null }> { + const selfLogMeta = input.resolvedUsage.selfLogBillingMeta; + const billingPricingOverride = toPricingOverride(selfLogMeta); + const cacheReadTokens = selfLogMeta?.cacheReadTokens ?? input.parsedUsage.cacheReadTokens; + const cacheCreationTokens = selfLogMeta?.cacheCreationTokens ?? input.parsedUsage.cacheCreationTokens; + const promptTokensIncludeCache = selfLogMeta?.promptTokensIncludeCache + ?? input.parsedUsage.promptTokensIncludeCache; + + const billingInput = { + site: input.site, + account: input.account, + modelName: input.modelName, + promptTokens: input.resolvedUsage.promptTokens, + completionTokens: input.resolvedUsage.completionTokens, + totalTokens: input.resolvedUsage.totalTokens, + cacheReadTokens, + cacheCreationTokens, + promptTokensIncludeCache, + billingPricingOverride, + }; + + let estimatedCost = await estimateProxyCost(billingInput); + const billingDetails = await buildProxyBillingDetails(billingInput); + + if ( + input.resolvedUsage.estimatedCostFromQuota > 0 + && (input.resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0) + ) { + estimatedCost = input.resolvedUsage.estimatedCostFromQuota; + } + + return { + estimatedCost, + billingDetails, + }; +} diff --git a/src/server/routes/proxy/proxyFailureJudge.test.ts b/src/server/services/proxyFailureJudge.test.ts similarity index 98% rename from src/server/routes/proxy/proxyFailureJudge.test.ts rename to src/server/services/proxyFailureJudge.test.ts index 4b6b850..b8a875c 100644 --- a/src/server/routes/proxy/proxyFailureJudge.test.ts +++ b/src/server/services/proxyFailureJudge.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { config } from '../../config.js'; +import { config } from '../config.js'; import { detectProxyFailure } from './proxyFailureJudge.js'; describe('detectProxyFailure (empty content)', () => { diff --git a/src/server/routes/proxy/proxyFailureJudge.ts b/src/server/services/proxyFailureJudge.ts similarity index 98% rename from src/server/routes/proxy/proxyFailureJudge.ts rename to src/server/services/proxyFailureJudge.ts index 1e0c3b1..20a2435 100644 --- a/src/server/routes/proxy/proxyFailureJudge.ts +++ b/src/server/services/proxyFailureJudge.ts @@ -1,5 +1,5 @@ -import { config } from '../../config.js'; -import { pullSseDataEvents } from '../../services/proxyUsageParser.js'; +import { config } from '../config.js'; +import { pullSseDataEvents } from './proxyUsageParser.js'; type FailureResult = { status: number; diff --git a/src/server/routes/proxy/logPathMeta.test.ts b/src/server/services/proxyLogMessage.test.ts similarity index 96% rename from src/server/routes/proxy/logPathMeta.test.ts rename to src/server/services/proxyLogMessage.test.ts index 68b39ec..7476e07 100644 --- a/src/server/routes/proxy/logPathMeta.test.ts +++ b/src/server/services/proxyLogMessage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { composeProxyLogMessage } from './logPathMeta.js'; +import { composeProxyLogMessage } from './proxyLogMessage.js'; describe('composeProxyLogMessage', () => { it('adds downstream path metadata before plain errors', () => { diff --git a/src/server/services/proxyLogMessage.ts b/src/server/services/proxyLogMessage.ts new file mode 100644 index 0000000..2e7d482 --- /dev/null +++ b/src/server/services/proxyLogMessage.ts @@ -0,0 +1,51 @@ +import { + parseProxyLogMetadata, + type ParsedProxyLogMetadata, + type ProxyLogUsageSource, +} from '../../shared/proxyLogMeta.js'; + +type ComposeProxyLogMessageArgs = { + clientKind?: string | null; + sessionId?: string | null; + traceHint?: string | null; + downstreamPath?: string | null; + upstreamPath?: string | null; + usageSource?: ProxyLogUsageSource; + errorMessage?: string | null; +}; + +export type ParsedProxyLogMessageMeta = ParsedProxyLogMetadata; + +export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta { + return parseProxyLogMetadata(rawMessage); +} + +export function composeProxyLogMessage({ + clientKind, + sessionId, + traceHint, + downstreamPath, + upstreamPath, + usageSource, + errorMessage, +}: ComposeProxyLogMessageArgs): string | null { + const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : ''; + const parsed = parseProxyLogMessageMeta(rawMessage); + const finalClientKind = (clientKind || parsed.clientKind || '').trim(); + const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim(); + const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim(); + const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim(); + const finalUsageSource = (usageSource || parsed.usageSource || '').trim(); + const finalMessageText = parsed.messageText.trim(); + + const prefixParts: string[] = []; + if (finalClientKind) prefixParts.push(`[client:${finalClientKind}]`); + if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`); + if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`); + if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`); + if (finalUsageSource) prefixParts.push(`[usage:${finalUsageSource}]`); + + if (prefixParts.length === 0 && !finalMessageText) return null; + if (finalMessageText) return `${prefixParts.join(' ')} ${finalMessageText}`.trim(); + return prefixParts.join(' '); +} diff --git a/src/server/services/runtimeModelProbe.test.ts b/src/server/services/runtimeModelProbe.test.ts index 436129c..9e7c597 100644 --- a/src/server/services/runtimeModelProbe.test.ts +++ b/src/server/services/runtimeModelProbe.test.ts @@ -5,13 +5,15 @@ const buildUpstreamEndpointRequestMock = vi.fn(); const dispatchRuntimeRequestMock = vi.fn(); const resolveChannelProxyUrlMock = vi.fn(); const withSiteRecordProxyRequestInitMock = vi.fn(); +const getOauthInfoFromAccountMock = vi.fn(); +const buildOauthProviderHeadersMock = vi.fn(); -vi.mock('../routes/proxy/upstreamEndpoint.js', () => ({ +vi.mock('./upstreamEndpointRuntime.js', () => ({ resolveUpstreamEndpointCandidates: (...args: unknown[]) => resolveUpstreamEndpointCandidatesMock(...args), buildUpstreamEndpointRequest: (...args: unknown[]) => buildUpstreamEndpointRequestMock(...args), })); -vi.mock('../routes/proxy/runtimeExecutor.js', () => ({ +vi.mock('./runtimeDispatch.js', () => ({ dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args), })); @@ -20,6 +22,14 @@ vi.mock('./siteProxy.js', () => ({ withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args), })); +vi.mock('./oauth/oauthAccount.js', () => ({ + getOauthInfoFromAccount: (...args: unknown[]) => getOauthInfoFromAccountMock(...args), +})); + +vi.mock('./oauth/service.js', () => ({ + buildOauthProviderHeaders: (...args: unknown[]) => buildOauthProviderHeadersMock(...args), +})); + describe('probeRuntimeModel', () => { const site = { id: 1, @@ -46,7 +56,11 @@ describe('probeRuntimeModel', () => { dispatchRuntimeRequestMock.mockReset(); resolveChannelProxyUrlMock.mockReset(); withSiteRecordProxyRequestInitMock.mockReset(); + getOauthInfoFromAccountMock.mockReset(); + buildOauthProviderHeadersMock.mockReset(); + getOauthInfoFromAccountMock.mockReturnValue(null); + buildOauthProviderHeadersMock.mockReturnValue({}); buildUpstreamEndpointRequestMock.mockReturnValue({ path: '/v1/chat/completions', headers: { 'content-type': 'application/json' }, diff --git a/src/server/services/upstreamEndpointDerivation.test.ts b/src/server/services/upstreamEndpointDerivation.test.ts new file mode 100644 index 0000000..30e85ef --- /dev/null +++ b/src/server/services/upstreamEndpointDerivation.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise => null); + +vi.mock('./modelPricingService.js', () => ({ + fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg), +})); + +import { resolveUpstreamEndpointCandidates } from './upstreamEndpointDerivation.js'; +import { resetUpstreamEndpointRuntimeState } from './upstreamEndpointRuntimeMemory.js'; + +const baseContext = { + site: { + id: 1, + url: 'https://upstream.example.com', + platform: 'new-api', + apiKey: null, + }, + account: { + id: 2, + accessToken: 'token-demo', + apiToken: null, + }, +}; + +describe('upstreamEndpointDerivation', () => { + beforeEach(() => { + fetchModelPricingCatalogMock.mockReset(); + fetchModelPricingCatalogMock.mockResolvedValue(null); + resetUpstreamEndpointRuntimeState(); + }); + + it('derives compact requests directly to responses from the service owner', async () => { + const order = await resolveUpstreamEndpointCandidates( + baseContext, + 'gpt-5.3', + 'responses', + undefined, + undefined, + { + requestKind: 'responses-compact', + }, + ); + + expect(order).toEqual(['responses']); + }); + + it('derives codex oauth openai requests as responses-first without surface-local reordering', async () => { + const order = await resolveUpstreamEndpointCandidates( + baseContext, + 'gpt-5.3', + 'openai', + undefined, + undefined, + { + oauthProvider: 'codex', + }, + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + + it('keeps explicit openai platforms on responses-first ordering even for claude-family models', async () => { + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { + ...baseContext.site, + platform: 'openai', + }, + }, + 'claude-opus-4-6', + 'openai', + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + + it('keeps antigravity non-gemini compatibility requests on messages-first ordering', async () => { + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { + ...baseContext.site, + platform: 'antigravity', + }, + }, + 'claude-opus-4-6', + 'openai', + undefined, + { + hasNonImageFileInput: true, + }, + ); + + expect(order).toEqual(['messages']); + }); + + it('keeps claude-family file-url requests messages-first for claude upstreams', async () => { + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { + ...baseContext.site, + platform: 'claude', + }, + }, + 'claude-opus-4-6', + 'responses', + undefined, + { + hasNonImageFileInput: true, + }, + { + requiresNativeResponsesFileUrl: true, + }, + ); + + expect(order).toEqual(['messages']); + }); + + it('derives claude count_tokens requests as messages-only when the upstream supports messages', async () => { + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { + ...baseContext.site, + platform: 'openai', + }, + }, + 'claude-sonnet-4-5-20250929', + 'claude', + undefined, + undefined, + { + requestKind: 'claude-count-tokens', + }, + ); + + expect(order).toEqual(['messages']); + }); + + it('returns no candidates for claude count_tokens when the upstream does not support messages', async () => { + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { + ...baseContext.site, + platform: 'codex', + url: 'https://chatgpt.com/backend-api/codex', + }, + }, + 'gpt-5.4', + 'claude', + undefined, + undefined, + { + requestKind: 'claude-count-tokens', + }, + ); + + expect(order).toEqual([]); + }); +}); diff --git a/src/server/services/upstreamEndpointDerivation.ts b/src/server/services/upstreamEndpointDerivation.ts new file mode 100644 index 0000000..d6a714c --- /dev/null +++ b/src/server/services/upstreamEndpointDerivation.ts @@ -0,0 +1,322 @@ +import { + rankConversationFileEndpoints, + type ConversationFileInputSummary, +} from '../proxy-core/capabilities/conversationFileCapabilities.js'; +import type { UpstreamEndpoint } from '../proxy-core/orchestration/upstreamRequest.js'; +import { fetchModelPricingCatalog } from './modelPricingService.js'; +import { + applyUpstreamEndpointRuntimePreference, + buildEndpointCapabilityProfile, +} from './upstreamEndpointRuntimeMemory.js'; +import type { DownstreamFormat } from '../transformers/shared/normalized.js'; + +export type EndpointPreference = DownstreamFormat | 'responses'; +export type EndpointDerivationHints = { + oauthProvider?: string | null; + requestKind?: 'default' | 'responses-compact' | 'claude-count-tokens'; + requiresNativeResponsesFileUrl?: boolean; +}; + +type ChannelContext = { + site: { + id: number; + url: string; + platform: string; + apiKey?: string | null; + }; + account: { + id: number; + accessToken?: string | null; + apiToken?: string | null; + }; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizePlatformName(platform: unknown): string { + return asTrimmedString(platform).toLowerCase(); +} + +function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] { + const raw = asTrimmedString(value).toLowerCase(); + if (!raw) return []; + + const normalized = new Set(); + + if ( + raw.includes('/v1/messages') + || raw === 'messages' + || raw.includes('anthropic') + || raw.includes('claude') + ) { + normalized.add('messages'); + } + + if ( + raw.includes('/v1/responses') + || raw === 'responses' + || raw.includes('response') + ) { + normalized.add('responses'); + } + + if ( + raw.includes('/v1/chat/completions') + || raw.includes('chat/completions') + || raw === 'chat' + || raw === 'chat_completions' + || raw === 'completions' + || raw.includes('chat') + ) { + normalized.add('chat'); + } + + if (raw === 'openai' || raw.includes('openai')) { + normalized.add('chat'); + normalized.add('responses'); + } + + return Array.from(normalized); +} + +function preferredEndpointOrder( + downstreamFormat: EndpointPreference, + sitePlatform?: string, + preferMessagesForClaudeModel = false, + hints?: EndpointDerivationHints, +): UpstreamEndpoint[] { + const platform = normalizePlatformName(sitePlatform); + const oauthProvider = asTrimmedString(hints?.oauthProvider).toLowerCase(); + + if (hints?.requestKind === 'responses-compact') { + return ['responses']; + } + + if (platform === 'codex') { + return ['responses']; + } + + if (platform === 'gemini' || platform === 'gemini-cli') { + return ['chat']; + } + + if (platform === 'openai') { + return ['responses', 'chat', 'messages']; + } + + if (platform === 'antigravity') { + return ['messages']; + } + + if (platform === 'claude') { + return ['messages']; + } + + if (downstreamFormat === 'responses') { + if (preferMessagesForClaudeModel) { + return ['messages', 'chat', 'responses']; + } + return ['responses', 'chat', 'messages']; + } + + if (downstreamFormat === 'claude') { + return ['messages', 'chat', 'responses']; + } + + if (downstreamFormat === 'openai' && preferMessagesForClaudeModel) { + return ['messages', 'chat', 'responses']; + } + + const base = ['chat', 'messages', 'responses'] as UpstreamEndpoint[]; + if (oauthProvider === 'codex' && base.includes('responses')) { + return ['responses', ...base.filter((endpoint) => endpoint !== 'responses')]; + } + + return base; +} + +export async function resolveUpstreamEndpointCandidates( + context: ChannelContext, + modelName: string, + downstreamFormat: EndpointPreference, + requestedModelHint?: string, + requestCapabilities?: { + hasNonImageFileInput?: boolean; + conversationFileSummary?: ConversationFileInputSummary; + wantsNativeResponsesReasoning?: boolean; + wantsContinuationAwareResponses?: boolean; + }, + hints?: EndpointDerivationHints, +): Promise { + const sitePlatform = normalizePlatformName(context.site.platform); + if (hints?.requestKind === 'responses-compact') { + return ['responses']; + } + if ( + hints?.requiresNativeResponsesFileUrl + && sitePlatform !== 'claude' + && sitePlatform !== 'anyrouter' + ) { + return ['responses']; + } + + const capabilityProfile = buildEndpointCapabilityProfile({ + modelName, + requestedModelHint, + requestCapabilities, + }); + const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel; + const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput; + const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning; + const wantsContinuationAwareResponses = capabilityProfile.wantsContinuationAwareResponses; + const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => ( + applyUpstreamEndpointRuntimePreference(candidates, { + siteId: context.site.id, + downstreamFormat, + capabilityProfile, + }) + ); + const finalizeCandidates = (candidates: UpstreamEndpoint[]): UpstreamEndpoint[] => { + const preferredCandidates = applyRuntimePreference(candidates); + if (hints?.requestKind === 'claude-count-tokens') { + return preferredCandidates.includes('messages') ? ['messages'] : ([] as UpstreamEndpoint[]); + } + return preferredCandidates; + }; + const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? { + hasImage: false, + hasAudio: false, + hasDocument: hasNonImageFileInput, + hasRemoteDocumentUrl: false, + }; + + if (sitePlatform === 'anyrouter') { + if (hasNonImageFileInput) { + return finalizeCandidates(downstreamFormat === 'responses' + ? ['responses', 'messages', 'chat'] + : ['messages', 'responses', 'chat']); + } + if (downstreamFormat === 'responses') { + return finalizeCandidates(['responses', 'messages', 'chat']); + } + return finalizeCandidates(['messages', 'chat', 'responses']); + } + + const preferred = preferredEndpointOrder( + downstreamFormat, + context.site.platform, + preferMessagesForClaudeModel, + hints, + ); + const preferredWithCapabilities = hasNonImageFileInput + ? (() => { + if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[]; + if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[]; + if (sitePlatform === 'gemini-cli') return ['chat'] as UpstreamEndpoint[]; + if (sitePlatform === 'antigravity') return ['messages'] as UpstreamEndpoint[]; + if (sitePlatform === 'openai') return ['responses', 'chat', 'messages'] as UpstreamEndpoint[]; + return rankConversationFileEndpoints({ + sitePlatform, + requestedOrder: preferMessagesForClaudeModel + ? ['messages', 'responses', 'chat'] + : ['responses', 'messages', 'chat'], + summary: conversationFileSummary, + preferMessagesForClaudeModel, + }); + })() + : preferred; + const prioritizedPreferredEndpoints: UpstreamEndpoint[] = ( + preferredWithCapabilities.includes('responses') + && ( + wantsContinuationAwareResponses + || (wantsNativeResponsesReasoning && preferMessagesForClaudeModel) + ) + ) + ? [ + 'responses', + ...preferredWithCapabilities.filter((endpoint): endpoint is UpstreamEndpoint => endpoint !== 'responses'), + ] + : preferredWithCapabilities; + const forceMessagesFirstForClaudeModel = ( + downstreamFormat === 'openai' + && preferMessagesForClaudeModel + && sitePlatform !== 'openai' + && sitePlatform !== 'gemini' + && sitePlatform !== 'antigravity' + && sitePlatform !== 'gemini-cli' + ); + + try { + const catalog = await fetchModelPricingCatalog({ + site: { + id: context.site.id, + url: context.site.url, + platform: context.site.platform, + }, + account: { + id: context.account.id, + accessToken: context.account.accessToken ?? null, + apiToken: context.account.apiToken ?? null, + }, + modelName, + totalTokens: 0, + }); + + if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) { + return finalizeCandidates(prioritizedPreferredEndpoints); + } + + const matched = catalog.models.find((item) => + asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(), + ); + if (!matched) return finalizeCandidates(prioritizedPreferredEndpoints); + + const shouldIgnoreCatalogOrderingForClaudeMessages = ( + preferMessagesForClaudeModel + && (downstreamFormat !== 'responses' || sitePlatform !== 'openai') + ); + if (shouldIgnoreCatalogOrderingForClaudeMessages) { + return finalizeCandidates(prioritizedPreferredEndpoints); + } + + const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : []; + const normalizedSupportedRaw = supportedRaw + .map((item) => asTrimmedString(item).toLowerCase()) + .filter((item) => item.length > 0); + const hasConcreteEndpointHint = normalizedSupportedRaw.some((raw) => ( + raw.includes('/v1/messages') + || raw.includes('/v1/chat/completions') + || raw.includes('/v1/responses') + || raw === 'messages' + || raw === 'chat' + || raw === 'chat_completions' + || raw === 'completions' + || raw === 'responses' + )); + if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) { + return finalizeCandidates(prioritizedPreferredEndpoints); + } + + const supported = new Set(); + for (const endpoint of supportedRaw) { + const normalizedList = normalizeEndpointTypes(endpoint); + for (const normalized of normalizedList) { + supported.add(normalized); + } + } + + if (supported.size === 0) return finalizeCandidates(prioritizedPreferredEndpoints); + + const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint)); + if (!firstSupported) return finalizeCandidates(prioritizedPreferredEndpoints); + + return finalizeCandidates([ + firstSupported, + ...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported), + ]); + } catch { + return finalizeCandidates(prioritizedPreferredEndpoints); + } +} diff --git a/src/server/services/upstreamEndpointRuntime.ts b/src/server/services/upstreamEndpointRuntime.ts index 48aa10d..e365609 100644 --- a/src/server/services/upstreamEndpointRuntime.ts +++ b/src/server/services/upstreamEndpointRuntime.ts @@ -1,6 +1,10 @@ export { buildClaudeCountTokensUpstreamRequest, buildUpstreamEndpointRequest, +} from './upstreamRequestBuilder.js'; +export type { UpstreamEndpoint } from '../proxy-core/orchestration/upstreamRequest.js'; +export { resolveUpstreamEndpointCandidates, - type UpstreamEndpoint, -} from '../routes/proxy/upstreamEndpoint.js'; + type EndpointPreference, + type EndpointDerivationHints, +} from './upstreamEndpointDerivation.js'; diff --git a/src/server/services/upstreamEndpointRuntimeMemory.ts b/src/server/services/upstreamEndpointRuntimeMemory.ts index c81cc6f..cc29ca9 100644 --- a/src/server/services/upstreamEndpointRuntimeMemory.ts +++ b/src/server/services/upstreamEndpointRuntimeMemory.ts @@ -2,7 +2,10 @@ import { createHash } from 'node:crypto'; import type { ConversationFileInputSummary } from '../proxy-core/capabilities/conversationFileCapabilities.js'; import type { DownstreamFormat } from '../transformers/shared/normalized.js'; import { + inferSuggestedEndpointFromUpstreamError, + inferRequiredEndpointFromProtocolError, isEndpointDispatchDeniedError, + isEndpointDowngradeError, isUnsupportedMediaTypeError, } from '../transformers/shared/endpointCompatibility.js'; @@ -200,38 +203,30 @@ function maybeDeleteEndpointRuntimeState(key: string, nowMs = Date.now()): void } } -function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEndpointRuntimeEndpoint | null { - const text = (errorText || '').toLowerCase(); - if (!text) return null; - if (text.includes('/v1/responses')) return 'responses'; - if (text.includes('/v1/messages')) return 'messages'; - if (text.includes('/v1/chat/completions')) return 'chat'; - return null; +function inferSuggestedEndpointFromError( + endpoint: UpstreamEndpointRuntimeEndpoint, + errorText?: string | null, +): UpstreamEndpointRuntimeEndpoint | null { + const suggestedEndpoint = inferSuggestedEndpointFromUpstreamError(errorText); + return suggestedEndpoint && endpoint !== suggestedEndpoint ? suggestedEndpoint : null; } -function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean { +function shouldBlockEndpointByError( + endpoint: UpstreamEndpointRuntimeEndpoint, + status: number, + errorText?: string | null, +): boolean { if (isEndpointDispatchDeniedError(status, errorText)) return true; if (status === 404 || status === 405 || status === 415 || status === 501) return true; if (isUnsupportedMediaTypeError(status, errorText)) return true; - const text = (errorText || '').toLowerCase(); - return ( - text.includes('convert_request_failed') - || text.includes('endpoint_not_found') - || text.includes('unknown_endpoint') - || text.includes('unsupported_endpoint') - || text.includes('unsupported_path') - || text.includes('not_found_error') - || text.includes('unsupported legacy protocol') - || text.includes('please use /v1/') - || text.includes('does not allow /v1/') - || text.includes('unknown endpoint') - || text.includes('unsupported endpoint') - || text.includes('unsupported path') - || text.includes('unrecognized request url') - || text.includes('no route matched') - || text.includes('does not exist') - ); + const rawText = errorText || ''; + const requiredEndpoint = inferRequiredEndpointFromProtocolError(rawText); + if (requiredEndpoint) { + return endpoint !== requiredEndpoint; + } + + return isEndpointDowngradeError(status, errorText); } function shouldRememberSuccessfulEndpoint(input: { @@ -405,7 +400,7 @@ export function recordUpstreamEndpointFailure(input: { requestCapabilities: input.requestCapabilities, }); if (!shouldUseEndpointRuntimeMemory(capabilityProfile)) return null; - if (!shouldBlockEndpointByError(input.status, input.errorText)) return null; + if (!shouldBlockEndpointByError(input.endpoint, input.status, input.errorText)) return null; const nowMs = Date.now(); const key = buildEndpointRuntimeStateKey({ @@ -416,7 +411,7 @@ export function recordUpstreamEndpointFailure(input: { const state = getOrCreateEndpointRuntimeState(key, nowMs); state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS; - const suggestedEndpoint = inferSuggestedEndpointFromError(input.errorText); + const suggestedEndpoint = inferSuggestedEndpointFromError(input.endpoint, input.errorText); if (suggestedEndpoint && suggestedEndpoint !== input.endpoint) { state.preferredEndpoint = suggestedEndpoint; state.preferredUpdatedAtMs = nowMs; diff --git a/src/server/services/upstreamRequestBuilder.test.ts b/src/server/services/upstreamRequestBuilder.test.ts new file mode 100644 index 0000000..55b2a97 --- /dev/null +++ b/src/server/services/upstreamRequestBuilder.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildClaudeCountTokensUpstreamRequest, + buildUpstreamEndpointRequest, +} from './upstreamRequestBuilder.js'; + +describe('upstreamRequestBuilder', () => { + it('normalizes single-message OpenAI requests to structured responses input', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'upstream-gpt', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'sub2api', + siteUrl: 'https://example.com', + openaiBody: { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'hello' }], + }, + downstreamFormat: 'openai', + }); + + expect(request.path).toBe('/v1/responses'); + expect(request.headers.accept).toBe('application/json'); + expect(request.body.input).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ]); + expect(request.body.store).toBe(false); + }); + + it('forces store=false for sub2api native responses passthrough bodies', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'upstream-gpt', + stream: true, + tokenValue: 'sk-test', + sitePlatform: 'sub2api', + siteUrl: 'https://example.com', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.2', + input: 'hello', + store: true, + }, + }); + + expect(request.path).toBe('/v1/responses'); + expect(request.headers.accept).toBe('text/event-stream'); + expect(request.body.input).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ]); + expect(request.body.stream).toBe(true); + expect(request.body.store).toBe(false); + }); + + it('applies a sub2api-style allowlist to generic passthrough headers', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'chat', + modelName: 'upstream-gpt', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'sub2api', + siteUrl: 'https://example.com', + openaiBody: { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'hello' }], + }, + downstreamFormat: 'openai', + downstreamHeaders: { + accept: 'application/json', + 'accept-language': 'zh-CN', + 'user-agent': 'client-ua/1.0', + originator: 'codex_cli_rs', + session_id: 'session-123', + conversation_id: 'conversation-123', + 'x-codex-turn-state': 'turn-state', + 'x-codex-turn-metadata': 'turn-metadata', + origin: 'https://client.example', + referer: 'https://client.example/chat', + 'x-forwarded-for': '203.0.113.1', + 'x-real-ip': '203.0.113.2', + version: '0.202.0', + 'x-test-header': 'drop-me', + }, + }); + + expect(request.headers.accept).toBe('application/json'); + expect(request.headers['accept-language']).toBe('zh-CN'); + expect(request.headers['user-agent']).toBe('client-ua/1.0'); + expect(request.headers.originator).toBe('codex_cli_rs'); + expect(request.headers.session_id).toBe('session-123'); + expect(request.headers.conversation_id).toBe('conversation-123'); + expect(request.headers['x-codex-turn-state']).toBe('turn-state'); + expect(request.headers['x-codex-turn-metadata']).toBe('turn-metadata'); + + expect(request.headers.origin).toBeUndefined(); + expect(request.headers.referer).toBeUndefined(); + expect(request.headers['x-forwarded-for']).toBeUndefined(); + expect(request.headers['x-real-ip']).toBeUndefined(); + expect(request.headers.version).toBeUndefined(); + expect(request.headers['x-test-header']).toBeUndefined(); + }); + + it('drops responses-style continuation fields before proxying Claude count_tokens upstream', () => { + const request = buildClaudeCountTokensUpstreamRequest({ + modelName: 'claude-opus-4-6', + tokenValue: 'sk-test', + sitePlatform: 'claude', + claudeBody: { + model: 'claude-opus-4-6', + max_tokens: 256, + previous_response_id: 'resp_prev_1', + prompt_cache_key: 'cache-key-1', + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(request.body).toMatchObject({ + model: 'claude-opus-4-6', + messages: [{ role: 'user' }], + }); + expect(request.body).not.toHaveProperty('previous_response_id'); + expect(request.body).not.toHaveProperty('prompt_cache_key'); + expect(request.body).not.toHaveProperty('max_tokens'); + expect(request.body).not.toHaveProperty('maxTokens'); + }); +}); diff --git a/src/server/services/upstreamRequestBuilder.ts b/src/server/services/upstreamRequestBuilder.ts new file mode 100644 index 0000000..7150675 --- /dev/null +++ b/src/server/services/upstreamRequestBuilder.ts @@ -0,0 +1,830 @@ +import type { UpstreamEndpoint } from '../proxy-core/orchestration/upstreamRequest.js'; +import { resolveProviderProfile } from '../proxy-core/providers/registry.js'; +import { config } from '../config.js'; +import { applyPayloadRules } from './payloadRules.js'; +import type { DownstreamFormat } from '../transformers/shared/normalized.js'; +import { + convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer, + sanitizeResponsesBodyForProxy as sanitizeResponsesBodyForProxyViaTransformer, +} from '../transformers/openai/responses/conversion.js'; +import { normalizeCodexResponsesBodyForProxy } from '../transformers/openai/responses/codexCompatibility.js'; +import { + convertOpenAiBodyToAnthropicMessagesBody, + sanitizeAnthropicMessagesBody, +} from '../transformers/anthropic/messages/conversion.js'; +import { + buildGeminiGenerateContentRequestFromOpenAi, +} from '../transformers/gemini/generate-content/requestBridge.js'; +import { + buildClaudeRuntimeHeaders, + getInputHeader, + headerValueToString, +} from '../proxy-core/providers/headerUtils.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 resolveRequestedModelForPayloadRules(input: { + modelName: string; + openaiBody: Record; + claudeOriginalBody?: Record; + responsesOriginalBody?: Record; +}): string { + return ( + asTrimmedString(input.responsesOriginalBody?.model) + || asTrimmedString(input.claudeOriginalBody?.model) + || asTrimmedString(input.openaiBody.model) + || asTrimmedString(input.modelName) + ); +} + +function normalizePlatformName(platform: unknown): string { + return asTrimmedString(platform).toLowerCase(); +} + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +const BLOCKED_PASSTHROUGH_HEADERS = new Set([ + 'host', + 'content-type', + 'content-length', + 'accept-encoding', + 'cookie', + 'authorization', + 'x-api-key', + 'x-goog-api-key', + 'sec-websocket-key', + 'sec-websocket-version', + 'sec-websocket-extensions', +]); +const GENERIC_PASSTHROUGH_ALLOWED_HEADERS = new Set([ + 'accept', + 'accept-language', + 'conversation-id', + 'conversation_id', + 'openai-beta', + 'originator', + 'session-id', + 'session_id', + 'user-agent', + 'x-codex-beta-features', + 'x-codex-turn-metadata', + 'x-codex-turn-state', +]); +const METAPI_INTERNAL_HEADER_BLOCKLIST = new Set([ + 'x-metapi-tester-request', + 'x-metapi-tester-forced-channel-id', + 'x-metapi-responses-websocket-mode', + 'x-metapi-responses-websocket-transport', +]); + +const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; + +function shouldSkipPassthroughHeader(key: string): boolean { + if (HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key)) return true; + if (METAPI_INTERNAL_HEADER_BLOCKLIST.has(key)) return true; + if (key.startsWith('x-metapi-')) return true; + if (!GENERIC_PASSTHROUGH_ALLOWED_HEADERS.has(key)) return true; + return false; +} + +function extractSafePassthroughHeaders( + headers?: Record, +): Record { + if (!headers) return {}; + + const forwarded: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.toLowerCase(); + if (!key || shouldSkipPassthroughHeader(key)) continue; + + const value = headerValueToString(rawValue); + if (!value) continue; + forwarded[key] = value; + } + + return forwarded; +} + +function extractClaudePassthroughHeaders( + headers?: Record, +): Record { + if (!headers) return {}; + + const forwarded: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.toLowerCase(); + const shouldForward = ( + key.startsWith('anthropic-') + || key.startsWith('x-claude-') + || key.startsWith('x-stainless-') + ); + if (!shouldForward) continue; + + const value = headerValueToString(rawValue); + if (!value) continue; + forwarded[key] = value; + } + + return forwarded; +} + +function extractResponsesPassthroughHeaders( + headers?: Record, +): Record { + if (!headers) return {}; + + const forwarded: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.toLowerCase(); + const shouldForward = ( + key.startsWith('openai-') + || key.startsWith('x-openai-') + || key.startsWith('x-stainless-') + || key.startsWith('chatgpt-') + || key === 'originator' + ); + if (!shouldForward) continue; + + const value = headerValueToString(rawValue); + if (!value) continue; + forwarded[key] = value; + } + + return forwarded; +} + +function extractCodexPassthroughHeaders( + headers?: Record, +): Record { + if (!headers) return {}; + + const forwarded: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.toLowerCase(); + const shouldForward = ( + key === 'version' + || key === 'x-responsesapi-include-timing-metrics' + ); + if (!shouldForward) continue; + + const value = headerValueToString(rawValue); + if (!value) continue; + forwarded[key] = value; + } + + return forwarded; +} + +function extractClaudeBetasFromBody(body: Record): { + body: Record; + betas: string[]; +} { + const next = { ...body }; + const rawBetas = next.betas; + delete next.betas; + + if (typeof rawBetas === 'string') { + return { + body: next, + betas: rawBetas.split(',').map((entry) => entry.trim()).filter(Boolean), + }; + } + + if (Array.isArray(rawBetas)) { + return { + body: next, + betas: rawBetas + .map((entry) => asTrimmedString(entry)) + .filter(Boolean), + }; + } + + return { + body: next, + betas: [], + }; +} + +function stripClaudeMessagesContinuationFields( + body: Record, +): Record { + const next = { ...body }; + delete next.previous_response_id; + delete next.prompt_cache_key; + return next; +} + +function buildAntigravityRuntimeHeaders(input: { + baseHeaders: Record; + stream: boolean; +}): Record { + const headers: Record = { + Authorization: input.baseHeaders.Authorization, + 'Content-Type': 'application/json', + Accept: input.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT, + }; + return headers; +} + +function ensureStreamAcceptHeader( + headers: Record, + stream: boolean, +): Record { + if (!stream) return headers; + + const existingAccept = ( + headerValueToString(headers.accept) + || headerValueToString((headers as Record).Accept) + ); + if (existingAccept) return headers; + + return { + ...headers, + accept: 'text/event-stream', + }; +} + +function ensureResponsesAcceptHeader( + headers: Record, + input: { + stream: boolean; + sitePlatform?: string; + }, +): Record { + const existingAccept = ( + headerValueToString(headers.accept) + || headerValueToString((headers as Record).Accept) + ); + if (existingAccept) return headers; + if (input.stream) { + return { + ...headers, + accept: 'text/event-stream', + }; + } + if (normalizePlatformName(input.sitePlatform) === 'sub2api') { + return { + ...headers, + accept: 'application/json', + }; + } + return headers; +} + +function normalizeResponsesFallbackChatFunctionTool(rawTool: unknown): Record | null { + if (!isRecord(rawTool)) return null; + if (asTrimmedString(rawTool.type).toLowerCase() !== 'function') return null; + + if (isRecord(rawTool.function)) { + const name = asTrimmedString(rawTool.function.name); + if (!name) return null; + return { + ...rawTool, + type: 'function', + function: { + ...rawTool.function, + name, + }, + }; + } + + const name = asTrimmedString(rawTool.name); + if (!name) return null; + + const fn: Record = { name }; + const description = asTrimmedString(rawTool.description); + if (description) fn.description = description; + if (rawTool.parameters !== undefined) fn.parameters = rawTool.parameters; + if (rawTool.strict !== undefined) fn.strict = rawTool.strict; + + return { + type: 'function', + function: fn, + }; +} + +function normalizeResponsesFallbackChatToolChoice( + rawToolChoice: unknown, + allowedToolNames: Set, +): unknown { + if (rawToolChoice === undefined) return undefined; + + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'none') return 'none'; + if (allowedToolNames.size <= 0) return undefined; + if (normalized === 'auto' || normalized === 'required') return normalized; + return undefined; + } + + if (!isRecord(rawToolChoice)) return undefined; + if (asTrimmedString(rawToolChoice.type).toLowerCase() !== 'function') return undefined; + + const nestedFunction = isRecord(rawToolChoice.function) ? rawToolChoice.function : null; + const name = asTrimmedString(nestedFunction?.name ?? rawToolChoice.name); + if (!name || !allowedToolNames.has(name)) return undefined; + + return { + type: 'function', + function: { + ...(nestedFunction || {}), + name, + }, + }; +} + +function sanitizeResponsesFallbackChatBody( + body: Record, +): Record { + const next: Record = { ...body }; + const normalizedTools = Array.isArray(body.tools) + ? body.tools + .map((tool) => normalizeResponsesFallbackChatFunctionTool(tool)) + .filter((tool): tool is Record => !!tool) + : []; + + if (normalizedTools.length > 0) { + next.tools = normalizedTools; + } else { + delete next.tools; + } + + const allowedToolNames = new Set( + normalizedTools + .map((tool) => ( + isRecord(tool.function) + ? asTrimmedString(tool.function.name) + : '' + )) + .filter((name) => name.length > 0), + ); + const normalizedToolChoice = normalizeResponsesFallbackChatToolChoice( + body.tool_choice, + allowedToolNames, + ); + if (normalizedToolChoice !== undefined) { + next.tool_choice = normalizedToolChoice; + } else { + delete next.tool_choice; + } + + return next; +} + +function toFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function normalizeSub2ApiResponsesBodyForProxy( + body: Record, + sitePlatform: string, +): Record { + if (sitePlatform !== 'sub2api') return body; + return { + ...body, + store: false, + }; +} + +export function buildUpstreamEndpointRequest(input: { + endpoint: UpstreamEndpoint; + modelName: string; + stream: boolean; + tokenValue: string; + oauthProvider?: string; + oauthProjectId?: string; + sitePlatform?: string; + siteUrl?: string; + openaiBody: Record; + downstreamFormat: DownstreamFormat | 'responses'; + claudeOriginalBody?: Record; + forceNormalizeClaudeBody?: boolean; + responsesOriginalBody?: Record; + downstreamHeaders?: Record; + providerHeaders?: Record; + codexSessionCacheKey?: string | null; + codexExplicitSessionId?: string | null; +}): { + path: string; + headers: Record; + body: Record; + runtime?: { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }; +} { + const sitePlatform = normalizePlatformName(input.sitePlatform); + const providerProfile = resolveProviderProfile(sitePlatform); + const isClaudeUpstream = sitePlatform === 'claude'; + const isGeminiUpstream = sitePlatform === 'gemini'; + const isGeminiCliUpstream = sitePlatform === 'gemini-cli'; + const isAntigravityUpstream = sitePlatform === 'antigravity'; + const isInternalGeminiUpstream = isGeminiCliUpstream || isAntigravityUpstream; + const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude'; + + const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => { + const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase(); + const openAiCompatBase = /\/openai(?:\/|$)/.test(normalizedSiteUrl); + if (openAiCompatBase) { + return endpoint === 'responses' + ? '/responses' + : '/chat/completions'; + } + return endpoint === 'responses' + ? '/v1beta/openai/responses' + : '/v1beta/openai/chat/completions'; + }; + + const resolveEndpointPath = (endpoint: UpstreamEndpoint): string => { + if (isGeminiUpstream) { + return resolveGeminiEndpointPath(endpoint); + } + + if (sitePlatform === 'openai') { + if (endpoint === 'messages') return '/v1/messages'; + if (endpoint === 'responses') return '/v1/responses'; + return '/v1/chat/completions'; + } + + if (sitePlatform === 'codex') { + return '/responses'; + } + + if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') { + return input.stream + ? '/v1internal:streamGenerateContent?alt=sse' + : '/v1internal:generateContent'; + } + + if (sitePlatform === 'claude') { + return '/v1/messages'; + } + + if (endpoint === 'messages') return '/v1/messages'; + if (endpoint === 'responses') return '/v1/responses'; + return '/v1/chat/completions'; + }; + + const passthroughHeaders = extractSafePassthroughHeaders(input.downstreamHeaders); + const codexPassthroughHeaders = sitePlatform === 'codex' + ? extractCodexPassthroughHeaders(input.downstreamHeaders) + : {}; + const commonHeaders: Record = { + ...passthroughHeaders, + ...codexPassthroughHeaders, + 'Content-Type': 'application/json', + ...(input.providerHeaders || {}), + }; + if (!isClaudeUpstream) { + commonHeaders.Authorization = `Bearer ${input.tokenValue}`; + } + + const stripGeminiUnsupportedFields = (body: Record) => { + const next = { ...body }; + if (isGeminiUpstream || isInternalGeminiUpstream) { + for (const key of [ + 'frequency_penalty', + 'presence_penalty', + 'logit_bias', + 'logprobs', + 'top_logprobs', + 'store', + ]) { + delete next[key]; + } + } + return next; + }; + + const openaiBody = stripGeminiUnsupportedFields(input.openaiBody); + const runtime = { + executor: ( + sitePlatform === 'codex' + ? 'codex' + : sitePlatform === 'gemini-cli' + ? 'gemini-cli' + : sitePlatform === 'antigravity' + ? 'antigravity' + : sitePlatform === 'claude' + ? 'claude' + : 'default' + ) as 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude', + modelName: input.modelName, + stream: input.stream, + oauthProjectId: asTrimmedString(input.oauthProjectId) || null, + }; + const requestedModelForPayloadRules = resolveRequestedModelForPayloadRules(input); + const applyConfiguredPayloadRules = >(body: T): T => ( + applyPayloadRules({ + rules: config.payloadRules, + payload: body, + modelName: input.modelName, + requestedModel: requestedModelForPayloadRules, + protocol: sitePlatform, + }) as T + ); + + if (isInternalGeminiUpstream) { + const instructions = ( + input.downstreamFormat === 'responses' + && typeof input.responsesOriginalBody?.instructions === 'string' + ) + ? input.responsesOriginalBody.instructions + : undefined; + const geminiRequest = buildGeminiGenerateContentRequestFromOpenAi({ + body: openaiBody, + modelName: input.modelName, + instructions, + }); + const configuredGeminiRequest = applyConfiguredPayloadRules(geminiRequest); + if (!providerProfile) { + throw new Error(`missing provider profile for platform: ${sitePlatform}`); + } + return providerProfile.prepareRequest({ + endpoint: input.endpoint, + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: commonHeaders, + providerHeaders: input.providerHeaders, + body: configuredGeminiRequest, + action: input.stream ? 'streamGenerateContent' : 'generateContent', + }); + } + + if (input.endpoint === 'messages') { + const claudeHeaders = input.downstreamFormat === 'claude' + ? extractClaudePassthroughHeaders(input.downstreamHeaders) + : {}; + const anthropicVersion = ( + claudeHeaders['anthropic-version'] + || passthroughHeaders['anthropic-version'] + || '2023-06-01' + ); + const nativeClaudeBody = ( + input.downstreamFormat === 'claude' + && input.claudeOriginalBody + && input.forceNormalizeClaudeBody !== true + ) + ? { + ...stripClaudeMessagesContinuationFields(input.claudeOriginalBody), + model: input.modelName, + stream: input.stream, + } + : null; + const normalizedClaudeBody = ( + input.downstreamFormat === 'claude' + && input.claudeOriginalBody + && input.forceNormalizeClaudeBody === true + ) + ? sanitizeAnthropicMessagesBody({ + ...stripClaudeMessagesContinuationFields(input.claudeOriginalBody), + model: input.modelName, + stream: input.stream, + }) + : null; + const sanitizedBody = nativeClaudeBody + ?? normalizedClaudeBody + ?? sanitizeAnthropicMessagesBody( + convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream), + ); + const configuredClaudeBody = applyConfiguredPayloadRules(sanitizedBody); + + if (providerProfile?.id === 'claude') { + return providerProfile.prepareRequest({ + endpoint: 'messages', + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: commonHeaders, + claudeHeaders, + body: configuredClaudeBody, + }); + } + + const headers = buildClaudeRuntimeHeaders({ + baseHeaders: commonHeaders, + claudeHeaders, + anthropicVersion, + stream: input.stream, + isClaudeOauthUpstream, + tokenValue: input.tokenValue, + }); + + return { + path: resolveEndpointPath('messages'), + headers, + body: configuredClaudeBody, + runtime, + }; + } + + if (input.endpoint === 'responses') { + const responsesWebsocketTransport = getInputHeader( + input.downstreamHeaders, + 'x-metapi-responses-websocket-transport', + ) === '1'; + const websocketMode = Object.entries(input.downstreamHeaders || {}).find(([rawKey]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-mode'); + const preserveWebsocketIncrementalMode = asTrimmedString(websocketMode?.[1]).toLowerCase() === 'incremental'; + const responsesHeaders = input.downstreamFormat === 'responses' + ? extractResponsesPassthroughHeaders(input.downstreamHeaders) + : {}; + const rawBody = ( + input.downstreamFormat === 'responses' && input.responsesOriginalBody + ? { + ...stripGeminiUnsupportedFields(input.responsesOriginalBody), + model: input.modelName, + stream: input.stream, + } + : convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream) + ); + const sanitizedResponsesBody = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); + if (preserveWebsocketIncrementalMode && rawBody.generate === false) { + sanitizedResponsesBody.generate = false; + } + const body = normalizeCodexResponsesBodyForProxy( + sanitizedResponsesBody, + sitePlatform, + ); + const configuredResponsesBody = normalizeCodexResponsesBodyForProxy( + normalizeSub2ApiResponsesBodyForProxy( + applyConfiguredPayloadRules(body), + sitePlatform, + ), + sitePlatform, + ); + + if (sitePlatform === 'codex') { + if (providerProfile?.id !== 'codex') { + throw new Error(`missing codex provider profile for platform: ${sitePlatform}`); + } + return providerProfile.prepareRequest({ + endpoint: 'responses', + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: { + ...commonHeaders, + ...responsesHeaders, + }, + providerHeaders: input.providerHeaders, + codexSessionCacheKey: input.codexSessionCacheKey, + codexExplicitSessionId: input.codexExplicitSessionId, + responsesWebsocketTransport, + body: configuredResponsesBody, + }); + } + + const headers = ensureResponsesAcceptHeader({ + ...commonHeaders, + ...responsesHeaders, + }, { + stream: input.stream, + sitePlatform, + }); + return { + path: resolveEndpointPath('responses'), + headers, + body: configuredResponsesBody, + runtime, + }; + } + + const headers = ensureStreamAcceptHeader(commonHeaders, input.stream); + const chatBody = { + ...openaiBody, + model: input.modelName, + stream: input.stream, + }; + const configuredChatBody = applyConfiguredPayloadRules( + input.downstreamFormat === 'responses' + ? sanitizeResponsesFallbackChatBody(chatBody) + : chatBody, + ); + return { + path: resolveEndpointPath('chat'), + headers, + body: configuredChatBody, + runtime, + }; +} + +export function buildClaudeCountTokensUpstreamRequest(input: { + modelName: string; + tokenValue: string; + oauthProvider?: string; + sitePlatform?: string; + claudeBody: Record; + downstreamHeaders?: Record; +}): { + path: string; + headers: Record; + body: Record; + runtime: { + executor: 'claude'; + modelName: string; + stream: false; + action: 'countTokens'; + }; +} { + const sitePlatform = normalizePlatformName(input.sitePlatform); + const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders); + const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({ + ...stripClaudeMessagesContinuationFields(input.claudeBody), + model: input.modelName, + }); + const sanitizedBody = sanitizeAnthropicMessagesBody(bodyWithoutBetas); + delete sanitizedBody.max_tokens; + delete sanitizedBody.maxTokens; + delete sanitizedBody.stream; + const providerProfile = resolveProviderProfile(sitePlatform); + const effectiveClaudeHeaders = { + ...claudeHeaders, + ...(betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}), + }; + + if (providerProfile?.id === 'claude') { + const prepared = providerProfile.prepareRequest({ + endpoint: 'messages', + modelName: input.modelName, + stream: false, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + sitePlatform, + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: effectiveClaudeHeaders, + body: sanitizedBody, + action: 'countTokens', + }); + + return { + path: prepared.path, + headers: prepared.headers, + body: prepared.body, + runtime: { + executor: 'claude', + modelName: input.modelName, + stream: false, + action: 'countTokens', + }, + }; + } + + const anthropicVersion = ( + effectiveClaudeHeaders['anthropic-version'] + || '2023-06-01' + ); + const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude'; + const headers = buildClaudeRuntimeHeaders({ + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: effectiveClaudeHeaders, + anthropicVersion, + stream: false, + isClaudeOauthUpstream, + tokenValue: input.tokenValue, + }); + + return { + path: '/v1/messages/count_tokens?beta=true', + headers, + body: sanitizedBody, + runtime: { + executor: 'claude', + modelName: input.modelName, + stream: false, + action: 'countTokens', + }, + }; +} diff --git a/src/server/shared/codexClientFamily.test.ts b/src/server/shared/codexClientFamily.test.ts new file mode 100644 index 0000000..9d4b311 --- /dev/null +++ b/src/server/shared/codexClientFamily.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + detectCodexOfficialClientApp, + inferCodexOfficialOriginator, + isCodexOfficialClientHeaders, +} from './codexClientFamily.js'; + +describe('codexClientFamily helpers', () => { + it('detects official codex clients from user-agent and canonicalizes their originator', () => { + expect(detectCodexOfficialClientApp({ + 'user-agent': 'Mozilla/5.0 codex_exec/1.2.3', + })).toEqual({ + clientAppId: 'codex_exec', + clientAppName: 'Codex Exec', + originator: 'codex_exec', + }); + expect(inferCodexOfficialOriginator({ + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + })).toBe('codex_chatgpt_desktop'); + }); + + it('canonicalizes legacy originator spellings for official codex desktop clients', () => { + expect(detectCodexOfficialClientApp({ + originator: 'Codex Desktop', + })).toEqual({ + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + originator: 'codex_chatgpt_desktop', + }); + expect(inferCodexOfficialOriginator({ + originator: 'Codex Desktop', + })).toBe('codex_chatgpt_desktop'); + }); + + it('keeps generic codex family detection broad without inventing a specific originator', () => { + expect(isCodexOfficialClientHeaders({ + 'user-agent': 'Codex 1.0', + })).toBe(true); + expect(inferCodexOfficialOriginator({ + 'user-agent': 'Codex 1.0', + })).toBeNull(); + }); +}); diff --git a/src/server/shared/codexClientFamily.ts b/src/server/shared/codexClientFamily.ts new file mode 100644 index 0000000..6cc425b --- /dev/null +++ b/src/server/shared/codexClientFamily.ts @@ -0,0 +1,166 @@ +type CodexOfficialClientRule = { + clientAppId: string; + clientAppName: string; + originator: string; + userAgentPrefixes: readonly string[]; + originatorPrefixes: readonly string[]; +}; + +export type CodexOfficialClientApp = Pick< + CodexOfficialClientRule, + 'clientAppId' | 'clientAppName' | 'originator' +>; + +const CODEX_OFFICIAL_CLIENT_RULES: readonly CodexOfficialClientRule[] = [ + { + clientAppId: 'codex_cli_rs', + clientAppName: 'Codex CLI', + originator: 'codex_cli_rs', + userAgentPrefixes: ['codex_cli_rs/'], + originatorPrefixes: ['codex_cli_rs'], + }, + { + clientAppId: 'codex_vscode', + clientAppName: 'Codex VSCode', + originator: 'codex_vscode', + userAgentPrefixes: ['codex_vscode/'], + originatorPrefixes: ['codex_vscode'], + }, + { + clientAppId: 'codex_app', + clientAppName: 'Codex App', + originator: 'codex_app', + userAgentPrefixes: ['codex_app/'], + originatorPrefixes: ['codex_app'], + }, + { + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + originator: 'codex_chatgpt_desktop', + userAgentPrefixes: ['codex_chatgpt_desktop/', 'codex desktop/'], + originatorPrefixes: ['codex_chatgpt_desktop', 'codex desktop'], + }, + { + clientAppId: 'codex_atlas', + clientAppName: 'Codex Atlas', + originator: 'codex_atlas', + userAgentPrefixes: ['codex_atlas/'], + originatorPrefixes: ['codex_atlas'], + }, + { + clientAppId: 'codex_exec', + clientAppName: 'Codex Exec', + originator: 'codex_exec', + userAgentPrefixes: ['codex_exec/'], + originatorPrefixes: ['codex_exec'], + }, + { + clientAppId: 'codex_sdk_ts', + clientAppName: 'Codex SDK TS', + originator: 'codex_sdk_ts', + userAgentPrefixes: ['codex_sdk_ts/'], + originatorPrefixes: ['codex_sdk_ts'], + }, +]; + +const CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES = [ + ...new Set(CODEX_OFFICIAL_CLIENT_RULES.flatMap((rule) => rule.userAgentPrefixes)), + 'codex ', +] as const; + +const CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES = [ + 'codex_', + 'codex ', +] as const; + +function normalizeHeaderValue(value: string): string { + return value.trim().toLowerCase(); +} + +function headerValueToStrings(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + if (!Array.isArray(value)) return []; + + const values: string[] = []; + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) values.push(trimmed); + } + return values; +} + +function getHeaderValues( + headers: Record | undefined, + targetKey: string, +): string[] { + if (!headers) return []; + const normalizedTarget = normalizeHeaderValue(targetKey); + const values: string[] = []; + for (const [rawKey, rawValue] of Object.entries(headers)) { + if (normalizeHeaderValue(rawKey) !== normalizedTarget) continue; + values.push(...headerValueToStrings(rawValue)); + } + return values; +} + +function matchesCodexClientPrefixes( + value: string | string[] | null | undefined, + prefixes: readonly string[], +): boolean { + const normalizedValues = Array.isArray(value) + ? value.map((item) => normalizeHeaderValue(item)).filter(Boolean) + : (typeof value === 'string' ? [normalizeHeaderValue(value)] : []).filter(Boolean); + if (normalizedValues.length <= 0) return false; + + return normalizedValues.some((normalizedValue) => prefixes.some((prefix) => { + const normalizedPrefix = normalizeHeaderValue(prefix); + return !!normalizedPrefix + && (normalizedValue.startsWith(normalizedPrefix) || normalizedValue.includes(normalizedPrefix)); + })); +} + +export function detectCodexOfficialClientApp( + headers?: Record, +): CodexOfficialClientApp | null { + for (const rule of CODEX_OFFICIAL_CLIENT_RULES) { + const matchesOriginator = matchesCodexClientPrefixes( + getHeaderValues(headers, 'originator'), + rule.originatorPrefixes, + ); + const matchesUserAgent = matchesCodexClientPrefixes( + getHeaderValues(headers, 'user-agent'), + rule.userAgentPrefixes, + ); + if (!matchesOriginator && !matchesUserAgent) continue; + return { + clientAppId: rule.clientAppId, + clientAppName: rule.clientAppName, + originator: rule.originator, + }; + } + return null; +} + +export function inferCodexOfficialOriginator( + headers?: Record, +): string | null { + return detectCodexOfficialClientApp(headers)?.originator || null; +} + +export function isCodexOfficialClientHeaders( + headers?: Record, +): boolean { + if (detectCodexOfficialClientApp(headers)) return true; + return matchesCodexClientPrefixes( + getHeaderValues(headers, 'originator'), + CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES, + ) || matchesCodexClientPrefixes( + getHeaderValues(headers, 'user-agent'), + CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES, + ); +} diff --git a/src/server/transformers/anthropic/messages/compatibility.ts b/src/server/transformers/anthropic/messages/compatibility.ts index 6dff710..cdaef93 100644 --- a/src/server/transformers/anthropic/messages/compatibility.ts +++ b/src/server/transformers/anthropic/messages/compatibility.ts @@ -1,4 +1,5 @@ import { type DownstreamFormat } from '../../shared/normalized.js'; +import { inferRequiredEndpointFromProtocolError } from '../../shared/endpointCompatibility.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); @@ -61,9 +62,9 @@ export function shouldRetryNormalizedMessagesBody(input: { if (input.downstreamFormat !== 'claude') return false; if (!input.endpointPath.includes('/v1/messages')) return false; if (input.status < 400 || input.status >= 500) return false; - return /messages\s+is\s+required/i.test(input.upstreamErrorText); + return inferRequiredEndpointFromProtocolError(input.upstreamErrorText) === 'messages'; } export function isMessagesRequiredError(upstreamErrorText: string): boolean { - return /messages\s+is\s+required/i.test(upstreamErrorText); + return inferRequiredEndpointFromProtocolError(upstreamErrorText) === 'messages'; } diff --git a/src/server/transformers/anthropic/messages/index.test.ts b/src/server/transformers/anthropic/messages/index.test.ts index 6fdd37f..bfdda7e 100644 --- a/src/server/transformers/anthropic/messages/index.test.ts +++ b/src/server/transformers/anthropic/messages/index.test.ts @@ -15,6 +15,7 @@ describe('anthropicMessagesTransformer protocol contract', () => { ], metadata: { user_id: 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account__session_11111111-2222-3333-4444-555555555555', + metapi_turn_state: 'turn-state-claude-1', }, }); @@ -25,6 +26,10 @@ describe('anthropicMessagesTransformer protocol contract', () => { cliProfile: 'generic', requestedModel: 'claude-sonnet-4-5', stream: false, + continuation: { + sessionId: 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account__session_11111111-2222-3333-4444-555555555555', + turnState: 'turn-state-claude-1', + }, messages: [ { role: 'user', @@ -98,12 +103,20 @@ describe('anthropicMessagesTransformer protocol contract', () => { parts: [{ type: 'text', text: 'count these tokens' }], }, ], + continuation: { + sessionId: 'session-claude-bridge-1', + turnState: 'turn-state-claude-bridge-1', + }, tools: [{ name: 'lookup', inputSchema: { type: 'object' } }], }); expect(body).toMatchObject({ model: 'claude-sonnet-4-5', max_tokens: 4096, + metadata: { + user_id: 'session-claude-bridge-1', + metapi_turn_state: 'turn-state-claude-bridge-1', + }, messages: [ { role: 'user', diff --git a/src/server/transformers/anthropic/messages/index.ts b/src/server/transformers/anthropic/messages/index.ts index 9154eb7..11c9121 100644 --- a/src/server/transformers/anthropic/messages/index.ts +++ b/src/server/transformers/anthropic/messages/index.ts @@ -1,12 +1,18 @@ -import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js'; import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js'; import { anthropicMessagesInbound } from './inbound.js'; -import { convertOpenAiBodyToAnthropicMessagesBody } from './conversion.js'; -import { anthropicMessagesOutbound } from './outbound.js'; -import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js'; +import { + buildCanonicalRequestToAnthropicMessagesBody, + parseAnthropicMessagesRequestToCanonical, +} from './requestBridge.js'; +import { + anthropicMessagesResponseBridge, + buildNormalizedFinalToAnthropicMessagesBody, + normalizeAnthropicMessagesFinalToNormalized, +} from './responseBridge.js'; +import { anthropicMessagesStream, consumeAnthropicSseEvent } from './streamBridge.js'; import { anthropicMessagesUsage } from './usage.js'; import { createAnthropicMessagesAggregateState } from './aggregator.js'; import { @@ -21,12 +27,12 @@ export { serializeAnthropicUpstreamFinalAsStream, serializeAnthropicRawSseEvent, syncAnthropicRawStreamStateFromEvent, -} from './stream.js'; +} from './streamBridge.js'; export const anthropicMessagesTransformer = { protocol: 'anthropic/messages' as const, inbound: anthropicMessagesInbound, - outbound: anthropicMessagesOutbound, + outbound: anthropicMessagesResponseBridge, stream: anthropicMessagesStream, usage: anthropicMessagesUsage, compatibility: { @@ -41,42 +47,13 @@ export const anthropicMessagesTransformer = { body: unknown, ctx?: ProtocolParseContext, ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { - const parsed = anthropicMessagesInbound.parse(body); - if (parsed.error) { - return { error: parsed.error }; - } - if (!parsed.value) { - return { - error: { - statusCode: 400, - payload: { - error: { - message: 'invalid messages request', - type: 'invalid_request_error', - }, - }, - }, - }; - } - - return { - value: canonicalRequestFromOpenAiBody({ - body: parsed.value.parsed.upstreamBody, - surface: 'anthropic-messages', - cliProfile: ctx?.cliProfile, - operation: ctx?.operation, - metadata: ctx?.metadata, - passthrough: ctx?.passthrough, - continuation: ctx?.continuation, - }), - }; + return parseAnthropicMessagesRequestToCanonical(body, ctx); }, buildProtocolRequest( request: CanonicalRequestEnvelope, _ctx?: ProtocolBuildContext, ): Record { - const openAiBody = canonicalRequestToOpenAiChatBody(request); - return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream); + return buildCanonicalRequestToAnthropicMessagesBody(request); }, transformRequest(body: unknown): ReturnType { return anthropicMessagesInbound.parse(body); @@ -88,7 +65,7 @@ export const anthropicMessagesTransformer = { return anthropicMessagesStream.createDownstreamContext(); }, transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse { - return anthropicMessagesOutbound.normalizeFinal(payload, modelName, fallbackText); + return normalizeAnthropicMessagesFinalToNormalized(payload, modelName, fallbackText); }, transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent { return anthropicMessagesStream.normalizeEvent(payload, context, modelName); @@ -108,9 +85,9 @@ export const anthropicMessagesTransformer = { }, serializeFinalResponse( normalized: NormalizedFinalResponse, - usage: Parameters[1], + usage: Parameters[1], ) { - return anthropicMessagesOutbound.serializeFinal(normalized, usage); + return buildNormalizedFinalToAnthropicMessagesBody(normalized, usage); }, serializeUpstreamFinalAsStream( payload: unknown, @@ -123,7 +100,7 @@ export const anthropicMessagesTransformer = { payload, modelName, fallbackText, - anthropicMessagesOutbound.normalizeFinal, + normalizeAnthropicMessagesFinalToNormalized, streamContext, claudeContext, ); diff --git a/src/server/transformers/anthropic/messages/outbound.ts b/src/server/transformers/anthropic/messages/outbound.ts index d052410..f859f75 100644 --- a/src/server/transformers/anthropic/messages/outbound.ts +++ b/src/server/transformers/anthropic/messages/outbound.ts @@ -1,181 +1,7 @@ -import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js'; -import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js'; -import { toAnthropicUsagePayload } from './usage.js'; - -type AnthropicRecord = Record; - -type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & { - nativeContent?: AnthropicRecord[]; - stopSequence?: string | null; - usagePayload?: AnthropicRecord; -}; - -function isRecord(value: unknown): value is AnthropicRecord { - 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 asTrimmedString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function parseJsonLike(raw: string): unknown { - const trimmed = raw.trim(); - if (!trimmed) return {}; - try { - return JSON.parse(trimmed); - } catch { - return { value: raw }; - } -} - -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 cleanAnthropicReasoningSignature(value: unknown): string | null { - const raw = typeof value === 'string' ? value.trim() : ''; - if (!raw) return null; - const decoded = decodeAnthropicReasoningSignature(raw); - if (decoded) return decoded; - if (raw.startsWith('metapi:')) return null; - return raw; -} - -function buildAnthropicContent(normalized: NormalizedFinalResponse): Array> { - const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse; - if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) { - return anthropicNormalized.nativeContent.map((block) => { - const cloned = cloneJsonValue(block); - const blockType = asTrimmedString(cloned.type).toLowerCase(); - if (blockType === 'thinking') { - const signature = cleanAnthropicReasoningSignature(cloned.signature); - if (signature) cloned.signature = signature; - else delete cloned.signature; - } - return cloned; - }); - } - - const contentBlocks: Array> = []; - const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature); - if (normalized.reasoningContent || cleanSignature) { - const thinkingBlock: Record = { - type: 'thinking', - thinking: normalized.reasoningContent || '', - }; - if (cleanSignature) { - thinkingBlock.signature = cleanSignature; - } - contentBlocks.push(thinkingBlock); - } - if (normalized.redactedReasoningContent) { - contentBlocks.push({ - type: 'redacted_thinking', - data: normalized.redactedReasoningContent, - }); - } - if (normalized.content) { - contentBlocks.push({ - type: 'text', - text: normalized.content, - }); - } - const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : []; - 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 || ''), - }); - } - return contentBlocks.length > 0 - ? contentBlocks - : [{ type: 'text', text: '' }]; -} - -function extractNativeAnthropicContent(payload: unknown): AnthropicRecord[] | undefined { - if (!isRecord(payload) || !Array.isArray(payload.content)) return undefined; - const content = payload.content - .filter((block): block is AnthropicRecord => isRecord(block)) - .map((block) => cloneJsonValue(block)); - return content.length > 0 ? content : undefined; -} - -function extractStopSequence(payload: unknown): string | null { - if (!isRecord(payload) || typeof payload.stop_sequence !== 'string') return null; - return payload.stop_sequence; -} - -function extractUsagePayload(payload: unknown): AnthropicRecord | undefined { - if (!isRecord(payload) || !isRecord(payload.usage)) return undefined; - return cloneJsonValue(payload.usage); -} - -function mergeUsagePayload( - usage: unknown, - normalized: AnthropicMessagesNormalizedFinalResponse, -): AnthropicRecord { - const merged = toAnthropicUsagePayload(usage ?? normalized.usagePayload); - const usagePayload = normalized.usagePayload; - if (!usagePayload) return merged; - - for (const [key, value] of Object.entries(usagePayload)) { - if (key === 'input_tokens' || key === 'output_tokens') continue; - if (key === 'cache_creation' && isRecord(value) && isRecord(merged.cache_creation)) { - merged.cache_creation = { - ...cloneJsonValue(value), - ...merged.cache_creation, - }; - continue; - } - if (merged[key] === undefined) { - merged[key] = cloneJsonValue(value); - } - } - - return merged; -} - -export const anthropicMessagesOutbound = { - normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): AnthropicMessagesNormalizedFinalResponse { - const nativeContent = extractNativeAnthropicContent(payload); - const stopSequence = extractStopSequence(payload); - const usagePayload = extractUsagePayload(payload); - - return { - ...normalizeUpstreamFinalResponse(payload, modelName, fallbackText), - ...(nativeContent ? { nativeContent } : {}), - ...(stopSequence !== null ? { stopSequence } : {}), - ...(usagePayload ? { usagePayload } : {}), - }; - }, - serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) { - const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse; - return { - id: buildClaudeMessageId(normalized.id), - type: 'message', - role: 'assistant', - model: normalized.model, - content: buildAnthropicContent(normalized), - stop_reason: toClaudeStopReason(normalized.finishReason), - stop_sequence: anthropicNormalized.stopSequence ?? null, - usage: mergeUsagePayload(usage, anthropicNormalized), - }; - }, -}; +export { + anthropicMessagesOutbound, + anthropicMessagesResponseBridge, + buildNormalizedFinalToAnthropicMessagesBody, + normalizeAnthropicMessagesFinalToNormalized, +} from './responseBridge.js'; +export type { AnthropicMessagesNormalizedFinalResponse } from './responseBridge.js'; diff --git a/src/server/transformers/anthropic/messages/requestBridge.test.ts b/src/server/transformers/anthropic/messages/requestBridge.test.ts new file mode 100644 index 0000000..33f6785 --- /dev/null +++ b/src/server/transformers/anthropic/messages/requestBridge.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCanonicalRequestToAnthropicMessagesBody, + parseAnthropicMessagesRequestToCanonical, +} from './requestBridge.js'; + +describe('anthropic messages request bridge', () => { + it('parses Anthropic messages bodies into canonical envelopes', () => { + const result = parseAnthropicMessagesRequestToCanonical({ + model: 'claude-sonnet-4-5', + max_tokens: 256, + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }, + ], + metadata: { + user_id: 'session-claude-1', + metapi_turn_state: 'turn-state-claude-1', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + surface: 'anthropic-messages', + requestedModel: 'claude-sonnet-4-5', + continuation: { + sessionId: 'session-claude-1', + turnState: 'turn-state-claude-1', + }, + }); + }); + + it('builds Anthropic messages bodies from canonical envelopes', () => { + const body = buildCanonicalRequestToAnthropicMessagesBody({ + operation: 'generate', + surface: 'anthropic-messages', + cliProfile: 'claude_code', + requestedModel: 'claude-sonnet-4-5', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + sessionId: 'session-claude-1', + turnState: 'turn-state-claude-1', + }, + }); + + expect(body).toMatchObject({ + model: 'claude-sonnet-4-5', + metadata: { + user_id: 'session-claude-1', + metapi_turn_state: 'turn-state-claude-1', + }, + }); + }); +}); diff --git a/src/server/transformers/anthropic/messages/requestBridge.ts b/src/server/transformers/anthropic/messages/requestBridge.ts new file mode 100644 index 0000000..774b666 --- /dev/null +++ b/src/server/transformers/anthropic/messages/requestBridge.ts @@ -0,0 +1,47 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/openAiRequestBridge.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolParseContext } from '../../contracts.js'; +import { anthropicMessagesInbound } from './inbound.js'; +import { convertOpenAiBodyToAnthropicMessagesBody } from './conversion.js'; + +export function parseAnthropicMessagesRequestToCanonical( + body: unknown, + ctx?: ProtocolParseContext, +): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = anthropicMessagesInbound.parse(body); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid messages request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + return { + value: canonicalRequestFromOpenAiBody({ + body: parsed.value.parsed.upstreamBody, + surface: 'anthropic-messages', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; +} + +export function buildCanonicalRequestToAnthropicMessagesBody( + request: CanonicalRequestEnvelope, +): Record { + const openAiBody = canonicalRequestToOpenAiChatBody(request); + return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream); +} diff --git a/src/server/transformers/anthropic/messages/responseBridge.test.ts b/src/server/transformers/anthropic/messages/responseBridge.test.ts new file mode 100644 index 0000000..5c348b0 --- /dev/null +++ b/src/server/transformers/anthropic/messages/responseBridge.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; + +import { + anthropicMessagesResponseBridge, + buildNormalizedFinalToAnthropicMessagesBody, + normalizeAnthropicMessagesFinalToNormalized, +} from './responseBridge.js'; +import { anthropicMessagesOutbound } from './outbound.js'; +import { anthropicMessagesUsage } from './usage.js'; + +describe('anthropic messages response bridge', () => { + it('normalizes native anthropic finals into normalized final responses', () => { + const normalized = normalizeAnthropicMessagesFinalToNormalized({ + id: 'msg_native_1', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { + type: 'thinking', + thinking: 'plan first', + signature: 'sig-native', + }, + { + type: 'text', + text: 'done', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 30, + }, + }, 'claude-test'); + + expect(normalized).toMatchObject({ + id: 'msg_native_1', + model: 'claude-test', + content: 'done', + reasoningContent: 'plan first', + }); + }); + + it('builds anthropic messages payloads from normalized final responses', () => { + const upstreamPayload = { + id: 'msg_native_1', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { + type: 'thinking', + thinking: 'plan first', + signature: 'sig-native', + }, + { + type: 'text', + text: 'done', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 30, + }, + }; + + const normalized = normalizeAnthropicMessagesFinalToNormalized(upstreamPayload, 'claude-test'); + const payload = buildNormalizedFinalToAnthropicMessagesBody( + normalized, + anthropicMessagesUsage.fromPayload(upstreamPayload), + ); + + expect(payload).toEqual({ + id: 'msg_native_1', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { + type: 'thinking', + thinking: 'plan first', + signature: 'sig-native', + }, + { + type: 'text', + text: 'done', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 30, + }, + }); + }); + + it('preserves responses output-item ordering when serializing to anthropic blocks', () => { + const normalized = normalizeAnthropicMessagesFinalToNormalized({ + id: 'resp_order_1', + object: 'response', + model: 'gpt-5', + status: 'completed', + output: [ + { + id: 'rs_reasoning_1', + type: 'reasoning', + encrypted_content: 'metapi:anthropic-signature:sig-resp-1', + summary: [ + { type: 'summary_text', text: 'plan first' }, + ], + }, + { + id: 'fc_weather_1', + type: 'function_call', + call_id: 'call_weather_1', + name: 'lookup_weather', + arguments: '{"city":"Paris"}', + }, + { + id: 'msg_order_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: 'done' }, + ], + }, + ], + usage: { + input_tokens: 12, + output_tokens: 6, + }, + }, 'gpt-5'); + + const payload = buildNormalizedFinalToAnthropicMessagesBody(normalized, { + promptTokens: 12, + completionTokens: 6, + totalTokens: 18, + }); + + expect(payload.content).toEqual([ + { + type: 'thinking', + thinking: 'plan first', + signature: 'sig-resp-1', + }, + { + type: 'tool_use', + id: 'call_weather_1', + name: 'lookup_weather', + input: { city: 'Paris' }, + }, + { + type: 'text', + text: 'done', + }, + ]); + }); + + it('maps responses web_search_call items into anthropic server tool blocks', () => { + const normalized = normalizeAnthropicMessagesFinalToNormalized({ + id: 'resp_search_1', + object: 'response', + model: 'gpt-5', + status: 'completed', + output: [ + { + id: 'ws_1', + type: 'web_search_call', + status: 'completed', + action: { + query: 'weather in Paris', + }, + }, + { + id: 'msg_search_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: 'It is sunny.' }, + ], + }, + ], + usage: { + input_tokens: 9, + output_tokens: 4, + }, + }, 'gpt-5'); + + const payload = buildNormalizedFinalToAnthropicMessagesBody(normalized, { + promptTokens: 9, + completionTokens: 4, + totalTokens: 13, + }); + + expect(payload.content).toEqual([ + { + type: 'server_tool_use', + id: 'srvtoolu_ws_1', + name: 'web_search', + input: { + query: 'weather in Paris', + }, + }, + { + type: 'web_search_tool_result', + tool_use_id: 'srvtoolu_ws_1', + content: [], + }, + { + type: 'text', + text: 'It is sunny.', + }, + ]); + }); + + it('keeps the outbound facade pointed at the response bridge object', () => { + expect(anthropicMessagesOutbound).toBe(anthropicMessagesResponseBridge); + }); +}); diff --git a/src/server/transformers/anthropic/messages/responseBridge.ts b/src/server/transformers/anthropic/messages/responseBridge.ts new file mode 100644 index 0000000..94c035f --- /dev/null +++ b/src/server/transformers/anthropic/messages/responseBridge.ts @@ -0,0 +1,273 @@ +import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js'; +import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js'; +import { toAnthropicUsagePayload } from './usage.js'; + +type AnthropicRecord = Record; + +export type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & { + nativeContent?: AnthropicRecord[]; + stopSequence?: string | null; + usagePayload?: AnthropicRecord; +}; + +function isRecord(value: unknown): value is AnthropicRecord { + 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 asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseJsonLike(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { value: raw }; + } +} + +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 cleanAnthropicReasoningSignature(value: unknown): string | null { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw) return null; + const decoded = decodeAnthropicReasoningSignature(raw); + if (decoded) return decoded; + if (raw.startsWith('metapi:')) return null; + return raw; +} + +function buildAnthropicContent(normalized: NormalizedFinalResponse): Array> { + const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse; + if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) { + return anthropicNormalized.nativeContent.map((block) => { + const cloned = cloneJsonValue(block); + const blockType = asTrimmedString(cloned.type).toLowerCase(); + if (blockType === 'thinking') { + const signature = cleanAnthropicReasoningSignature(cloned.signature); + if (signature) cloned.signature = signature; + else delete cloned.signature; + } + return cloned; + }); + } + + const contentBlocks: Array> = []; + const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature); + if (normalized.reasoningContent || cleanSignature) { + const thinkingBlock: Record = { + type: 'thinking', + thinking: normalized.reasoningContent || '', + }; + if (cleanSignature) { + thinkingBlock.signature = cleanSignature; + } + contentBlocks.push(thinkingBlock); + } + if (normalized.redactedReasoningContent) { + contentBlocks.push({ + type: 'redacted_thinking', + data: normalized.redactedReasoningContent, + }); + } + if (normalized.content) { + contentBlocks.push({ + type: 'text', + text: normalized.content, + }); + } + const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : []; + 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 || ''), + }); + } + return contentBlocks.length > 0 + ? contentBlocks + : [{ type: 'text', text: '' }]; +} + +function extractNativeAnthropicContent(payload: unknown): AnthropicRecord[] | undefined { + if (!isRecord(payload) || !Array.isArray(payload.content)) return undefined; + const content = payload.content + .filter((block): block is AnthropicRecord => isRecord(block)) + .map((block) => cloneJsonValue(block)); + return content.length > 0 ? content : undefined; +} + +function extractAnthropicContentFromResponsesPayload(payload: unknown): AnthropicRecord[] | undefined { + if (!isRecord(payload) || !Array.isArray(payload.output)) return undefined; + + const contentBlocks: AnthropicRecord[] = []; + + for (const outputItem of payload.output) { + if (!isRecord(outputItem)) continue; + const itemType = asTrimmedString(outputItem.type).toLowerCase(); + + if (itemType === 'reasoning') { + const summary = Array.isArray(outputItem.summary) ? outputItem.summary : []; + const thinking = summary + .filter((part): part is AnthropicRecord => isRecord(part)) + .map((part) => asTrimmedString(part.text)) + .filter((text) => text.length > 0) + .join(''); + const block: AnthropicRecord = { + type: 'thinking', + thinking, + }; + const signature = cleanAnthropicReasoningSignature(outputItem.encrypted_content); + if (signature) { + block.signature = signature; + } + contentBlocks.push(block); + continue; + } + + if (itemType === 'message') { + const parts = Array.isArray(outputItem.content) ? outputItem.content : []; + for (const part of parts) { + if (!isRecord(part)) continue; + const partType = asTrimmedString(part.type).toLowerCase(); + if ((partType === 'output_text' || partType === 'text') && typeof part.text === 'string') { + contentBlocks.push({ + type: 'text', + text: part.text, + }); + } + } + continue; + } + + if (itemType === 'function_call') { + const toolUseId = asTrimmedString(outputItem.call_id) || asTrimmedString(outputItem.id) || `toolu_${contentBlocks.length}`; + contentBlocks.push({ + type: 'tool_use', + id: toolUseId, + name: asTrimmedString(outputItem.name) || 'tool', + input: parseJsonLike(typeof outputItem.arguments === 'string' ? outputItem.arguments : ''), + }); + continue; + } + + if (itemType === 'web_search_call') { + const sourceId = asTrimmedString(outputItem.id) || `${contentBlocks.length}`; + const toolUseId = `srvtoolu_${sourceId}`; + const action = isRecord(outputItem.action) ? outputItem.action : null; + const query = asTrimmedString(action?.query); + + contentBlocks.push({ + type: 'server_tool_use', + id: toolUseId, + name: 'web_search', + input: { + query, + }, + }); + contentBlocks.push({ + type: 'web_search_tool_result', + tool_use_id: toolUseId, + content: [], + }); + } + } + + return contentBlocks.length > 0 ? contentBlocks : undefined; +} + +function extractStopSequence(payload: unknown): string | null { + if (!isRecord(payload) || typeof payload.stop_sequence !== 'string') return null; + return payload.stop_sequence; +} + +function extractUsagePayload(payload: unknown): AnthropicRecord | undefined { + if (!isRecord(payload) || !isRecord(payload.usage)) return undefined; + return cloneJsonValue(payload.usage); +} + +function mergeUsagePayload( + usage: unknown, + normalized: AnthropicMessagesNormalizedFinalResponse, +): AnthropicRecord { + const merged = toAnthropicUsagePayload(usage ?? normalized.usagePayload); + const usagePayload = normalized.usagePayload; + if (!usagePayload) return merged; + + for (const [key, value] of Object.entries(usagePayload)) { + if (key === 'input_tokens' || key === 'output_tokens') continue; + if (key === 'cache_creation' && isRecord(value) && isRecord(merged.cache_creation)) { + merged.cache_creation = { + ...cloneJsonValue(value), + ...merged.cache_creation, + }; + continue; + } + if (merged[key] === undefined) { + merged[key] = cloneJsonValue(value); + } + } + + return merged; +} + +export function normalizeAnthropicMessagesFinalToNormalized( + payload: unknown, + modelName: string, + fallbackText = '', +): AnthropicMessagesNormalizedFinalResponse { + const nativeContent = extractNativeAnthropicContent(payload) ?? extractAnthropicContentFromResponsesPayload(payload); + const stopSequence = extractStopSequence(payload); + const usagePayload = extractUsagePayload(payload); + + return { + ...normalizeUpstreamFinalResponse(payload, modelName, fallbackText), + ...(nativeContent ? { nativeContent } : {}), + ...(stopSequence !== null ? { stopSequence } : {}), + ...(usagePayload ? { usagePayload } : {}), + }; +} + +export function buildNormalizedFinalToAnthropicMessagesBody( + normalized: NormalizedFinalResponse, + usage?: unknown, +) { + const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse; + return { + id: buildClaudeMessageId(normalized.id), + type: 'message', + role: 'assistant', + model: normalized.model, + content: buildAnthropicContent(normalized), + stop_reason: toClaudeStopReason(normalized.finishReason), + stop_sequence: anthropicNormalized.stopSequence ?? null, + usage: mergeUsagePayload(usage, anthropicNormalized), + }; +} + +export const anthropicMessagesResponseBridge = { + normalizeFinal: normalizeAnthropicMessagesFinalToNormalized, + serializeFinal: buildNormalizedFinalToAnthropicMessagesBody, +}; + +export const anthropicMessagesOutbound = anthropicMessagesResponseBridge; diff --git a/src/server/transformers/anthropic/messages/stream.ts b/src/server/transformers/anthropic/messages/stream.ts index 579b88d..5c80ea8 100644 --- a/src/server/transformers/anthropic/messages/stream.ts +++ b/src/server/transformers/anthropic/messages/stream.ts @@ -1,1113 +1,11 @@ -import { - createClaudeDownstreamContext, - createStreamTransformContext, - normalizeStopReason, - normalizeUpstreamStreamEvent, - pullSseEventsWithDone, - type ClaudeDownstreamContext, - type NormalizedFinalResponse, - type NormalizedStreamEvent, - type StreamTransformContext, -} from '../../shared/normalized.js'; -import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js'; -import { type AnthropicExtendedStreamEvent } from './aggregator.js'; - -type AnthropicStreamPayload = Record; -type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & { - nativeContent?: AnthropicStreamPayload[]; -}; - -type AnthropicBlockKind = 'thinking' | 'text' | 'tool_use' | 'redacted_thinking'; - -type ExtendedToolBlockState = { - contentIndex: number; - id: string; - name: string; - open: boolean; - sourceIndex: number | null; -}; - -type ExtendedClaudeDownstreamContext = ClaudeDownstreamContext & { - toolBlocks: Record; - thinkingBlockIndex?: number | null; - thinkingSourceIndex?: number | null; - redactedBlockIndex?: number | null; - redactedSourceIndex?: number | null; - textSourceIndex?: number | null; - pendingSignature?: string | null; - activeToolSlot?: number | 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`; -} - -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 cleanAnthropicReasoningSignature(value: unknown): string | null { - const raw = typeof value === 'string' ? value.trim() : ''; - if (!raw) return null; - const decoded = decodeAnthropicReasoningSignature(raw); - if (decoded) return decoded; - if (raw.startsWith('metapi:')) return null; - return raw; -} - -function buildAnthropicFinalContentBlocks( - normalizedFinal: NormalizedFinalResponse, -): AnthropicStreamPayload[] { - const anthropicNormalized = normalizedFinal as AnthropicMessagesNormalizedFinalResponse; - if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) { - return anthropicNormalized.nativeContent - .filter((block): block is AnthropicStreamPayload => isRecord(block)) - .map((block) => { - const cloned = cloneJsonValue(block); - const blockType = asTrimmedString(cloned.type).toLowerCase(); - if (blockType === 'thinking') { - const signature = cleanAnthropicReasoningSignature(cloned.signature); - if (signature) cloned.signature = signature; - else delete cloned.signature; - } - return cloned; - }); - } - - const contentBlocks: AnthropicStreamPayload[] = []; - const cleanSignature = cleanAnthropicReasoningSignature(normalizedFinal.reasoningSignature); - if (normalizedFinal.reasoningContent || cleanSignature) { - const thinkingBlock: AnthropicStreamPayload = { - type: 'thinking', - thinking: normalizedFinal.reasoningContent || '', - }; - if (cleanSignature) thinkingBlock.signature = cleanSignature; - contentBlocks.push(thinkingBlock); - } - if (normalizedFinal.redactedReasoningContent) { - contentBlocks.push({ - type: 'redacted_thinking', - data: normalizedFinal.redactedReasoningContent, - }); - } - if (normalizedFinal.content) { - contentBlocks.push({ - type: 'text', - text: normalizedFinal.content, - }); - } - if (Array.isArray(normalizedFinal.toolCalls)) { - for (let index = 0; index < normalizedFinal.toolCalls.length; index += 1) { - const toolCall = normalizedFinal.toolCalls[index]; - contentBlocks.push({ - type: 'tool_use', - id: toolCall.id || `toolu_${index}`, - name: toolCall.name || `tool_${index}`, - input: (() => { - const rawArguments = toolCall.arguments || ''; - try { - return rawArguments ? JSON.parse(rawArguments) : {}; - } catch { - return { value: rawArguments }; - } - })(), - }); - } - } - - if (contentBlocks.length <= 0) { - contentBlocks.push({ - type: 'text', - text: '', - }); - } - return contentBlocks; -} - -function serializeToolInputDelta(input: unknown): string | null { - if (input === undefined) return null; - if (typeof input === 'string') return input; - try { - return JSON.stringify(input); - } catch { - return JSON.stringify({}); - } -} - -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.thinkingSourceIndex === undefined) extended.thinkingSourceIndex = null; - if (extended.redactedBlockIndex === undefined) extended.redactedBlockIndex = null; - if (extended.redactedSourceIndex === undefined) extended.redactedSourceIndex = null; - if (extended.textSourceIndex === undefined) extended.textSourceIndex = null; - if (extended.pendingSignature === undefined) extended.pendingSignature = null; - if (extended.activeToolSlot === undefined) extended.activeToolSlot = 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.textSourceIndex = 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 bufferPendingSignature( - context: ExtendedClaudeDownstreamContext, - signature: string, -): void { - context.pendingSignature = `${context.pendingSignature || ''}${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; - context.thinkingSourceIndex = null; - return events; -} - -function closeRedactedBlock(context: ExtendedClaudeDownstreamContext): string[] { - if (context.redactedBlockIndex === null || context.redactedBlockIndex === undefined) return []; - const index = context.redactedBlockIndex; - context.redactedBlockIndex = null; - context.redactedSourceIndex = null; - return [ - serializeSse('content_block_stop', { - type: 'content_block_stop', - index, - }), - ]; -} - -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, - })); - } - context.activeToolSlot = null; - return events; -} - -function emitPendingSignatureAsThinkingBlock( - context: ExtendedClaudeDownstreamContext, -): string[] { - if (!context.pendingSignature) return []; - const signature = context.pendingSignature; - const events = [ - ...ensureThinkingBlockStart(context), - ]; - context.pendingSignature = signature; - events.push(...emitPendingSignature(context)); - events.push(...closeThinkingBlock(context)); - return events; -} - -function closeAllBlocks(context: ExtendedClaudeDownstreamContext): string[] { - return [ - ...closeTextBlock(context), - ...closeThinkingBlock(context), - ...closeRedactedBlock(context), - ...closeToolBlocks(context), - ]; -} - -function normalizeBlockIndex(value: unknown): number | null { - if (typeof value !== 'number' || !Number.isFinite(value)) return null; - return Math.max(0, Math.trunc(value)); -} - -function allocateContentIndex( - context: ExtendedClaudeDownstreamContext, - preferredIndex?: number | null, -): number { - const normalizedPreferred = normalizeBlockIndex(preferredIndex); - if (normalizedPreferred !== null) { - if (context.nextContentBlockIndex <= normalizedPreferred) { - context.nextContentBlockIndex = normalizedPreferred + 1; - } - return normalizedPreferred; - } - - const index = context.nextContentBlockIndex; - context.nextContentBlockIndex += 1; - return index; -} - -function ensureTextBlockStart( - context: ExtendedClaudeDownstreamContext, - preferredIndex?: number | null, -): string[] { - if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return []; - const index = allocateContentIndex(context, preferredIndex); - context.textBlockIndex = index; - context.textSourceIndex = normalizeBlockIndex(preferredIndex) ?? index; - context.contentBlockStarted = true; - return [ - serializeSse('content_block_start', { - type: 'content_block_start', - index, - content_block: { - type: 'text', - text: '', - }, - }), - ]; -} - -function ensureThinkingBlockStart( - context: ExtendedClaudeDownstreamContext, - preferredIndex?: number | null, -): string[] { - if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) return []; - const index = allocateContentIndex(context, preferredIndex); - context.thinkingBlockIndex = index; - context.thinkingSourceIndex = normalizeBlockIndex(preferredIndex) ?? index; - return [ - serializeSse('content_block_start', { - type: 'content_block_start', - index, - content_block: { - type: 'thinking', - thinking: '', - }, - }), - ]; -} - -function ensureRedactedBlockStart( - context: ExtendedClaudeDownstreamContext, - data: string, - preferredIndex?: number | null, -): string[] { - if (context.redactedBlockIndex !== null && context.redactedBlockIndex !== undefined) return []; - const index = allocateContentIndex(context, preferredIndex); - context.redactedBlockIndex = index; - context.redactedSourceIndex = normalizeBlockIndex(preferredIndex) ?? index; - return [ - serializeSse('content_block_start', { - type: 'content_block_start', - index, - content_block: { - type: 'redacted_thinking', - data, - }, - }), - ]; -} - -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: allocateContentIndex(context), - id: toolDelta.id || `toolu_${toolSlot}`, - name: toolDelta.name || `tool_${toolSlot}`, - open: false, - sourceIndex: toolSlot, - }; - 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: {}, - }, - })); - } - - context.activeToolSlot = toolSlot; - return { events, contentIndex: state.contentIndex }; -} - -function ensureExplicitToolBlockStart( - context: ExtendedClaudeDownstreamContext, - sourceIndex?: number | null, -): string[] { - const toolSlot = normalizeBlockIndex(sourceIndex) ?? 0; - const state = context.toolBlocks[toolSlot]; - const toolDelta: NonNullable[number] = { - index: toolSlot, - id: state?.id, - name: state?.name, - }; - return ensureToolBlockStart(context, toolDelta).events; -} - -function closePreviousToolBlockIfNeeded( - context: ExtendedClaudeDownstreamContext, - toolSlot: number, -): string[] { - const activeToolSlot = context.activeToolSlot; - if (activeToolSlot === null || activeToolSlot === undefined || activeToolSlot === toolSlot) { - return []; - } - return closeToolBlocks(context); -} - -function isMatchingBlockIndex( - targetIndex: number, - contentIndex: number | null | undefined, - sourceIndex: number | null | undefined, -): boolean { - if (sourceIndex !== null && sourceIndex !== undefined) return sourceIndex === targetIndex; - if (contentIndex !== null && contentIndex !== undefined) return contentIndex === targetIndex; - return false; -} - -function handleExplicitBlockStart( - kind: AnthropicBlockKind, - sourceIndex: number | null, - context: ExtendedClaudeDownstreamContext, -): string[] { - if (kind === 'thinking') { - return [ - ...closeRedactedBlock(context), - ...closeToolBlocks(context), - ...closeTextBlock(context), - ...ensureThinkingBlockStart(context, sourceIndex), - ]; - } - - if (kind === 'text') { - return [ - ...closeRedactedBlock(context), - ...closeToolBlocks(context), - ...closeThinkingBlock(context), - ...ensureTextBlockStart(context, sourceIndex), - ]; - } - - if (kind === 'tool_use') { - return [ - ...closeRedactedBlock(context), - ...closeTextBlock(context), - ...closeThinkingBlock(context), - ...closePreviousToolBlockIfNeeded(context, normalizeBlockIndex(sourceIndex) ?? 0), - ...ensureExplicitToolBlockStart(context, sourceIndex), - ]; - } - - return []; -} - -function buildDoneEvents( - streamContext: StreamTransformContext, - context: ExtendedClaudeDownstreamContext, - finishReason?: string | null, -): string[] { - if (context.doneSent) return []; - - const events = [ - ...ensureClaudeStartEvents(streamContext, context), - ...closeAllBlocks(context), - ...emitPendingSignatureAsThinkingBlock(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), - ]; - const serializeAnthropicEvent = (event: AnthropicExtendedStreamEvent) => anthropicMessagesStream.serializeEvent( - event, - streamContext, - downstreamContext, - ); - const contentBlocks = buildAnthropicFinalContentBlocks(normalizedFinal); - - for (let index = 0; index < contentBlocks.length; index += 1) { - const block = contentBlocks[index]; - const blockType = asTrimmedString(block.type).toLowerCase(); - - if (blockType === 'thinking') { - lines.push(...serializeAnthropicEvent( - { - anthropic: { - startBlock: { - kind: 'thinking', - index, - }, - }, - }, - )); - const thinkingText = asTrimmedString(block.thinking); - if (thinkingText) { - lines.push(...anthropicMessagesStream.serializeEvent( - { reasoningDelta: thinkingText }, - streamContext, - downstreamContext, - )); - } - const cleanSignature = cleanAnthropicReasoningSignature(block.signature); - if (cleanSignature) { - lines.push(...serializeAnthropicEvent( - { - anthropic: { - signatureDelta: cleanSignature, - }, - }, - )); - } - lines.push(...serializeAnthropicEvent( - { - anthropic: { - stopBlockIndex: index, - }, - }, - )); - continue; - } - - if (blockType === 'redacted_thinking') { - lines.push(...serializeAnthropicEvent( - { - anthropic: { - startBlock: { - kind: 'redacted_thinking', - index, - }, - redactedThinkingData: asTrimmedString(block.data), - }, - }, - )); - lines.push(...serializeAnthropicEvent( - { - anthropic: { - stopBlockIndex: index, - }, - }, - )); - continue; - } - - if (blockType === 'tool_use') { - const argumentsDelta = serializeToolInputDelta(block.input); - lines.push(...anthropicMessagesStream.serializeEvent( - { - toolCallDeltas: [{ - index, - id: asTrimmedString(block.id) || undefined, - name: asTrimmedString(block.name) || undefined, - ...(argumentsDelta !== null ? { argumentsDelta } : {}), - }], - }, - streamContext, - downstreamContext, - )); - lines.push(...serializeAnthropicEvent( - { - anthropic: { - stopBlockIndex: index, - }, - }, - )); - continue; - } - - lines.push(...serializeAnthropicEvent( - { - anthropic: { - startBlock: { - kind: 'text', - index, - }, - }, - }, - )); - if (typeof block.text === 'string') { - lines.push(...anthropicMessagesStream.serializeEvent( - { contentDelta: block.text }, - streamContext, - downstreamContext, - )); - } - lines.push(...serializeAnthropicEvent( - { - anthropic: { - stopBlockIndex: index, - }, - }, - )); - } - - 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 === 'text') { - return { - anthropic: { - startBlock: { - kind: 'text', - index, - }, - }, - }; - } - - 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 { - anthropic: { - startBlock: { - kind: 'tool_use', - index, - }, - }, - 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 startBlock = anthropicEvent.anthropic?.startBlock; - const normalizedStartIndex = normalizeBlockIndex(startBlock?.index); - - 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 (startBlock && startBlock.kind !== 'redacted_thinking') { - events.push(...handleExplicitBlockStart(startBlock.kind, normalizedStartIndex, context)); - } - - const signatureDelta = anthropicEvent.anthropic?.signatureDelta - ?? (typeof event.reasoningSignature === 'string' ? event.reasoningSignature : undefined); - const cleanSignatureDelta = cleanAnthropicReasoningSignature(signatureDelta); - if (cleanSignatureDelta) { - bufferPendingSignature(context, cleanSignatureDelta); - } - - if (anthropicEvent.anthropic?.redactedThinkingData) { - events.push(...closeToolBlocks(context)); - events.push(...closeTextBlock(context)); - events.push(...closeThinkingBlock(context)); - events.push(...ensureRedactedBlockStart( - context, - anthropicEvent.anthropic.redactedThinkingData, - normalizedStartIndex, - )); - } - - if (event.reasoningDelta) { - events.push(...closeRedactedBlock(context)); - events.push(...closeToolBlocks(context)); - events.push(...closeTextBlock(context)); - events.push(...ensureThinkingBlockStart(context, normalizedStartIndex)); - 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(...closeRedactedBlock(context)); - events.push(...closeTextBlock(context)); - events.push(...closeThinkingBlock(context)); - for (const toolDelta of event.toolCallDeltas) { - events.push(...closePreviousToolBlockIfNeeded( - context, - Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0, - )); - 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(...closeRedactedBlock(context)); - events.push(...closeToolBlocks(context)); - events.push(...closeThinkingBlock(context)); - events.push(...ensureTextBlockStart(context, normalizedStartIndex)); - 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 (isMatchingBlockIndex(targetIndex, context.thinkingBlockIndex, context.thinkingSourceIndex)) { - events.push(...closeThinkingBlock(context)); - } - if (isMatchingBlockIndex(targetIndex, context.textBlockIndex, context.textSourceIndex)) { - events.push(...closeTextBlock(context)); - } - if (isMatchingBlockIndex(targetIndex, context.redactedBlockIndex, context.redactedSourceIndex)) { - events.push(...closeRedactedBlock(context)); - } - const matchingToolBlock = Object.values(context.toolBlocks).find((item) => ( - item.open - && isMatchingBlockIndex(targetIndex, item.contentIndex, item.sourceIndex) - )); - if (matchingToolBlock) { - matchingToolBlock.open = false; - if (typeof context.activeToolSlot === 'number') { - const activeState = context.toolBlocks[context.activeToolSlot]; - if (activeState?.contentIndex === matchingToolBlock.contentIndex) { - context.activeToolSlot = null; - } - } - 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); - }, +export { + ANTHROPIC_RAW_SSE_EVENT_NAMES, + anthropicMessagesStream, 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, - ); - }, -}; + isAnthropicRawSseEventName, + serializeAnthropicFinalAsStream, + serializeAnthropicRawSseEvent, + serializeAnthropicUpstreamFinalAsStream, + syncAnthropicRawStreamStateFromEvent, +} from './streamBridge.js'; +export type { AnthropicConsumedSseEvent } from './streamBridge.js'; diff --git a/src/server/transformers/anthropic/messages/streamBridge.test.ts b/src/server/transformers/anthropic/messages/streamBridge.test.ts new file mode 100644 index 0000000..d2fb442 --- /dev/null +++ b/src/server/transformers/anthropic/messages/streamBridge.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; + +import { + anthropicMessagesStream, + consumeAnthropicSseEvent, + serializeAnthropicUpstreamFinalAsStream, +} from './streamBridge.js'; +import { normalizeAnthropicMessagesFinalToNormalized } from './responseBridge.js'; + +describe('anthropic messages stream bridge', () => { + it('passes through recognized raw anthropic SSE events and marks completion on message_stop', () => { + const streamContext = anthropicMessagesStream.createContext('claude-test'); + const downstreamContext = anthropicMessagesStream.createDownstreamContext(); + + const result = consumeAnthropicSseEvent( + { + event: 'message_stop', + data: JSON.stringify({ type: 'message_stop' }), + }, + streamContext, + downstreamContext, + 'claude-test', + ); + + expect(result.handled).toBe(true); + expect(result.done).toBe(true); + expect(result.lines.join('')).toContain('event: message_stop'); + }); + + it('serializes normalized upstream finals back into anthropic SSE blocks', () => { + const streamContext = anthropicMessagesStream.createContext('claude-test'); + const downstreamContext = anthropicMessagesStream.createDownstreamContext(); + + const lines = serializeAnthropicUpstreamFinalAsStream( + { + id: 'msg_native_1', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { + type: 'thinking', + thinking: 'plan first', + signature: 'sig-native', + }, + { + type: 'text', + text: 'done', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 30, + }, + }, + 'claude-test', + '', + normalizeAnthropicMessagesFinalToNormalized, + streamContext, + downstreamContext, + ); + + const parsed = anthropicMessagesStream.pullSseEvents(lines.join('')); + expect(parsed.events.some((event) => event.event === 'message_start')).toBe(true); + expect(parsed.events.some((event) => event.event === 'message_stop')).toBe(true); + expect(lines.join('')).toContain('sig-native'); + }); + + it('preserves responses output-item ordering when streaming anthropic fallback blocks', () => { + const streamContext = anthropicMessagesStream.createContext('claude-test'); + const downstreamContext = anthropicMessagesStream.createDownstreamContext(); + + const lines = serializeAnthropicUpstreamFinalAsStream( + { + id: 'resp_stream_order_1', + object: 'response', + model: 'gpt-5', + status: 'completed', + output: [ + { + id: 'rs_reasoning_stream_1', + type: 'reasoning', + encrypted_content: 'metapi:anthropic-signature:sig-stream-1', + summary: [{ type: 'summary_text', text: 'plan first' }], + }, + { + id: 'fc_stream_1', + type: 'function_call', + call_id: 'call_stream_1', + name: 'lookup_weather', + arguments: '{"city":"Paris"}', + }, + { + id: 'msg_stream_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'done' }], + }, + ], + usage: { + input_tokens: 10, + output_tokens: 4, + }, + }, + 'gpt-5', + '', + normalizeAnthropicMessagesFinalToNormalized, + streamContext, + downstreamContext, + ); + + const serialized = lines.join(''); + expect(serialized.indexOf('"type":"thinking"')).toBeGreaterThan(-1); + expect(serialized.indexOf('"type":"tool_use"')).toBeGreaterThan(serialized.indexOf('"type":"thinking"')); + expect(serialized.indexOf('"text":"done"')).toBeGreaterThan(serialized.indexOf('"type":"tool_use"')); + expect(serialized).toContain('"signature":"sig-stream-1"'); + }); + + it('serializes responses web_search_call items into anthropic server tool stream blocks', () => { + const streamContext = anthropicMessagesStream.createContext('claude-test'); + const downstreamContext = anthropicMessagesStream.createDownstreamContext(); + + const lines = serializeAnthropicUpstreamFinalAsStream( + { + id: 'resp_stream_search_1', + object: 'response', + model: 'gpt-5', + status: 'completed', + output: [ + { + id: 'ws_stream_1', + type: 'web_search_call', + status: 'completed', + action: { + query: 'weather in Paris', + }, + }, + { + id: 'msg_stream_search_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'It is sunny.' }], + }, + ], + usage: { + input_tokens: 8, + output_tokens: 3, + }, + }, + 'gpt-5', + '', + normalizeAnthropicMessagesFinalToNormalized, + streamContext, + downstreamContext, + ); + + const serialized = lines.join(''); + expect(serialized).toContain('"type":"server_tool_use"'); + expect(serialized).toContain('"name":"web_search"'); + expect(serialized).toContain('"type":"web_search_tool_result"'); + expect(serialized).toContain('"tool_use_id":"srvtoolu_ws_stream_1"'); + expect(serialized.indexOf('"type":"web_search_tool_result"')).toBeGreaterThan(serialized.indexOf('"type":"server_tool_use"')); + expect(serialized).toContain('"text":"It is sunny."'); + }); +}); diff --git a/src/server/transformers/anthropic/messages/streamBridge.ts b/src/server/transformers/anthropic/messages/streamBridge.ts new file mode 100644 index 0000000..1005ffe --- /dev/null +++ b/src/server/transformers/anthropic/messages/streamBridge.ts @@ -0,0 +1,1126 @@ +import { + createClaudeDownstreamContext, + createStreamTransformContext, + normalizeStopReason, + normalizeUpstreamStreamEvent, + pullSseEventsWithDone, + type ClaudeDownstreamContext, + type NormalizedFinalResponse, + type NormalizedStreamEvent, + type StreamTransformContext, +} from '../../shared/normalized.js'; +import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js'; +import { type AnthropicExtendedStreamEvent } from './aggregator.js'; + +type AnthropicStreamPayload = Record; +type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & { + nativeContent?: AnthropicStreamPayload[]; +}; + +type AnthropicBlockKind = 'thinking' | 'text' | 'tool_use' | 'redacted_thinking'; + +type ExtendedToolBlockState = { + contentIndex: number; + id: string; + name: string; + open: boolean; + sourceIndex: number | null; +}; + +type ExtendedClaudeDownstreamContext = ClaudeDownstreamContext & { + toolBlocks: Record; + thinkingBlockIndex?: number | null; + thinkingSourceIndex?: number | null; + redactedBlockIndex?: number | null; + redactedSourceIndex?: number | null; + textSourceIndex?: number | null; + pendingSignature?: string | null; + activeToolSlot?: number | 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`; +} + +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 cleanAnthropicReasoningSignature(value: unknown): string | null { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw) return null; + const decoded = decodeAnthropicReasoningSignature(raw); + if (decoded) return decoded; + if (raw.startsWith('metapi:')) return null; + return raw; +} + +function buildAnthropicFinalContentBlocks( + normalizedFinal: NormalizedFinalResponse, +): AnthropicStreamPayload[] { + const anthropicNormalized = normalizedFinal as AnthropicMessagesNormalizedFinalResponse; + if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) { + return anthropicNormalized.nativeContent + .filter((block): block is AnthropicStreamPayload => isRecord(block)) + .map((block) => { + const cloned = cloneJsonValue(block); + const blockType = asTrimmedString(cloned.type).toLowerCase(); + if (blockType === 'thinking') { + const signature = cleanAnthropicReasoningSignature(cloned.signature); + if (signature) cloned.signature = signature; + else delete cloned.signature; + } + return cloned; + }); + } + + const contentBlocks: AnthropicStreamPayload[] = []; + const cleanSignature = cleanAnthropicReasoningSignature(normalizedFinal.reasoningSignature); + if (normalizedFinal.reasoningContent || cleanSignature) { + const thinkingBlock: AnthropicStreamPayload = { + type: 'thinking', + thinking: normalizedFinal.reasoningContent || '', + }; + if (cleanSignature) thinkingBlock.signature = cleanSignature; + contentBlocks.push(thinkingBlock); + } + if (normalizedFinal.redactedReasoningContent) { + contentBlocks.push({ + type: 'redacted_thinking', + data: normalizedFinal.redactedReasoningContent, + }); + } + if (normalizedFinal.content) { + contentBlocks.push({ + type: 'text', + text: normalizedFinal.content, + }); + } + if (Array.isArray(normalizedFinal.toolCalls)) { + for (let index = 0; index < normalizedFinal.toolCalls.length; index += 1) { + const toolCall = normalizedFinal.toolCalls[index]; + contentBlocks.push({ + type: 'tool_use', + id: toolCall.id || `toolu_${index}`, + name: toolCall.name || `tool_${index}`, + input: (() => { + const rawArguments = toolCall.arguments || ''; + try { + return rawArguments ? JSON.parse(rawArguments) : {}; + } catch { + return { value: rawArguments }; + } + })(), + }); + } + } + + if (contentBlocks.length <= 0) { + contentBlocks.push({ + type: 'text', + text: '', + }); + } + return contentBlocks; +} + +function serializeToolInputDelta(input: unknown): string | null { + if (input === undefined) return null; + if (typeof input === 'string') return input; + try { + return JSON.stringify(input); + } catch { + return JSON.stringify({}); + } +} + +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.thinkingSourceIndex === undefined) extended.thinkingSourceIndex = null; + if (extended.redactedBlockIndex === undefined) extended.redactedBlockIndex = null; + if (extended.redactedSourceIndex === undefined) extended.redactedSourceIndex = null; + if (extended.textSourceIndex === undefined) extended.textSourceIndex = null; + if (extended.pendingSignature === undefined) extended.pendingSignature = null; + if (extended.activeToolSlot === undefined) extended.activeToolSlot = 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.textSourceIndex = 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 bufferPendingSignature( + context: ExtendedClaudeDownstreamContext, + signature: string, +): void { + context.pendingSignature = `${context.pendingSignature || ''}${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; + context.thinkingSourceIndex = null; + return events; +} + +function closeRedactedBlock(context: ExtendedClaudeDownstreamContext): string[] { + if (context.redactedBlockIndex === null || context.redactedBlockIndex === undefined) return []; + const index = context.redactedBlockIndex; + context.redactedBlockIndex = null; + context.redactedSourceIndex = null; + return [ + serializeSse('content_block_stop', { + type: 'content_block_stop', + index, + }), + ]; +} + +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, + })); + } + context.activeToolSlot = null; + return events; +} + +function emitPendingSignatureAsThinkingBlock( + context: ExtendedClaudeDownstreamContext, +): string[] { + if (!context.pendingSignature) return []; + const signature = context.pendingSignature; + const events = [ + ...ensureThinkingBlockStart(context), + ]; + context.pendingSignature = signature; + events.push(...emitPendingSignature(context)); + events.push(...closeThinkingBlock(context)); + return events; +} + +function closeAllBlocks(context: ExtendedClaudeDownstreamContext): string[] { + return [ + ...closeTextBlock(context), + ...closeThinkingBlock(context), + ...closeRedactedBlock(context), + ...closeToolBlocks(context), + ]; +} + +function normalizeBlockIndex(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return Math.max(0, Math.trunc(value)); +} + +function allocateContentIndex( + context: ExtendedClaudeDownstreamContext, + preferredIndex?: number | null, +): number { + const normalizedPreferred = normalizeBlockIndex(preferredIndex); + if (normalizedPreferred !== null) { + if (context.nextContentBlockIndex <= normalizedPreferred) { + context.nextContentBlockIndex = normalizedPreferred + 1; + } + return normalizedPreferred; + } + + const index = context.nextContentBlockIndex; + context.nextContentBlockIndex += 1; + return index; +} + +function ensureTextBlockStart( + context: ExtendedClaudeDownstreamContext, + preferredIndex?: number | null, +): string[] { + if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return []; + const index = allocateContentIndex(context, preferredIndex); + context.textBlockIndex = index; + context.textSourceIndex = normalizeBlockIndex(preferredIndex) ?? index; + context.contentBlockStarted = true; + return [ + serializeSse('content_block_start', { + type: 'content_block_start', + index, + content_block: { + type: 'text', + text: '', + }, + }), + ]; +} + +function ensureThinkingBlockStart( + context: ExtendedClaudeDownstreamContext, + preferredIndex?: number | null, +): string[] { + if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) return []; + const index = allocateContentIndex(context, preferredIndex); + context.thinkingBlockIndex = index; + context.thinkingSourceIndex = normalizeBlockIndex(preferredIndex) ?? index; + return [ + serializeSse('content_block_start', { + type: 'content_block_start', + index, + content_block: { + type: 'thinking', + thinking: '', + }, + }), + ]; +} + +function ensureRedactedBlockStart( + context: ExtendedClaudeDownstreamContext, + data: string, + preferredIndex?: number | null, +): string[] { + if (context.redactedBlockIndex !== null && context.redactedBlockIndex !== undefined) return []; + const index = allocateContentIndex(context, preferredIndex); + context.redactedBlockIndex = index; + context.redactedSourceIndex = normalizeBlockIndex(preferredIndex) ?? index; + return [ + serializeSse('content_block_start', { + type: 'content_block_start', + index, + content_block: { + type: 'redacted_thinking', + data, + }, + }), + ]; +} + +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: allocateContentIndex(context), + id: toolDelta.id || `toolu_${toolSlot}`, + name: toolDelta.name || `tool_${toolSlot}`, + open: false, + sourceIndex: toolSlot, + }; + 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: {}, + }, + })); + } + + context.activeToolSlot = toolSlot; + return { events, contentIndex: state.contentIndex }; +} + +function ensureExplicitToolBlockStart( + context: ExtendedClaudeDownstreamContext, + sourceIndex?: number | null, +): string[] { + const toolSlot = normalizeBlockIndex(sourceIndex) ?? 0; + const state = context.toolBlocks[toolSlot]; + const toolDelta: NonNullable[number] = { + index: toolSlot, + id: state?.id, + name: state?.name, + }; + return ensureToolBlockStart(context, toolDelta).events; +} + +function closePreviousToolBlockIfNeeded( + context: ExtendedClaudeDownstreamContext, + toolSlot: number, +): string[] { + const activeToolSlot = context.activeToolSlot; + if (activeToolSlot === null || activeToolSlot === undefined || activeToolSlot === toolSlot) { + return []; + } + return closeToolBlocks(context); +} + +function isMatchingBlockIndex( + targetIndex: number, + contentIndex: number | null | undefined, + sourceIndex: number | null | undefined, +): boolean { + if (sourceIndex !== null && sourceIndex !== undefined) return sourceIndex === targetIndex; + if (contentIndex !== null && contentIndex !== undefined) return contentIndex === targetIndex; + return false; +} + +function handleExplicitBlockStart( + kind: AnthropicBlockKind, + sourceIndex: number | null, + context: ExtendedClaudeDownstreamContext, +): string[] { + if (kind === 'thinking') { + return [ + ...closeRedactedBlock(context), + ...closeToolBlocks(context), + ...closeTextBlock(context), + ...ensureThinkingBlockStart(context, sourceIndex), + ]; + } + + if (kind === 'text') { + return [ + ...closeRedactedBlock(context), + ...closeToolBlocks(context), + ...closeThinkingBlock(context), + ...ensureTextBlockStart(context, sourceIndex), + ]; + } + + if (kind === 'tool_use') { + return [ + ...closeRedactedBlock(context), + ...closeTextBlock(context), + ...closeThinkingBlock(context), + ...closePreviousToolBlockIfNeeded(context, normalizeBlockIndex(sourceIndex) ?? 0), + ...ensureExplicitToolBlockStart(context, sourceIndex), + ]; + } + + return []; +} + +function buildDoneEvents( + streamContext: StreamTransformContext, + context: ExtendedClaudeDownstreamContext, + finishReason?: string | null, +): string[] { + if (context.doneSent) return []; + + const events = [ + ...ensureClaudeStartEvents(streamContext, context), + ...closeAllBlocks(context), + ...emitPendingSignatureAsThinkingBlock(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), + ]; + const serializeAnthropicEvent = (event: AnthropicExtendedStreamEvent) => anthropicMessagesStream.serializeEvent( + event, + streamContext, + downstreamContext, + ); + const contentBlocks = buildAnthropicFinalContentBlocks(normalizedFinal); + + for (let index = 0; index < contentBlocks.length; index += 1) { + const block = contentBlocks[index]; + const blockType = asTrimmedString(block.type).toLowerCase(); + + if (blockType === 'thinking') { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'thinking', + index, + }, + }, + }, + )); + const thinkingText = asTrimmedString(block.thinking); + if (thinkingText) { + lines.push(...anthropicMessagesStream.serializeEvent( + { reasoningDelta: thinkingText }, + streamContext, + downstreamContext, + )); + } + const cleanSignature = cleanAnthropicReasoningSignature(block.signature); + if (cleanSignature) { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + signatureDelta: cleanSignature, + }, + }, + )); + } + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } + + if (blockType === 'redacted_thinking') { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'redacted_thinking', + index, + }, + redactedThinkingData: asTrimmedString(block.data), + }, + }, + )); + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } + + if (blockType === 'tool_use') { + const argumentsDelta = serializeToolInputDelta(block.input); + lines.push(...anthropicMessagesStream.serializeEvent( + { + toolCallDeltas: [{ + index, + id: asTrimmedString(block.id) || undefined, + name: asTrimmedString(block.name) || undefined, + ...(argumentsDelta !== null ? { argumentsDelta } : {}), + }], + }, + streamContext, + downstreamContext, + )); + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } + + if (blockType === 'server_tool_use' || blockType === 'web_search_tool_result') { + lines.push(serializeSse('content_block_start', { + type: 'content_block_start', + index, + content_block: cloneJsonValue(block), + })); + lines.push(serializeSse('content_block_stop', { + type: 'content_block_stop', + index, + })); + continue; + } + + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'text', + index, + }, + }, + }, + )); + if (typeof block.text === 'string') { + lines.push(...anthropicMessagesStream.serializeEvent( + { contentDelta: block.text }, + streamContext, + downstreamContext, + )); + } + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + } + + 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 === 'text') { + return { + anthropic: { + startBlock: { + kind: 'text', + index, + }, + }, + }; + } + + 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 { + anthropic: { + startBlock: { + kind: 'tool_use', + index, + }, + }, + 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 startBlock = anthropicEvent.anthropic?.startBlock; + const normalizedStartIndex = normalizeBlockIndex(startBlock?.index); + + 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 (startBlock && startBlock.kind !== 'redacted_thinking') { + events.push(...handleExplicitBlockStart(startBlock.kind, normalizedStartIndex, context)); + } + + const signatureDelta = anthropicEvent.anthropic?.signatureDelta + ?? (typeof event.reasoningSignature === 'string' ? event.reasoningSignature : undefined); + const cleanSignatureDelta = cleanAnthropicReasoningSignature(signatureDelta); + if (cleanSignatureDelta) { + bufferPendingSignature(context, cleanSignatureDelta); + } + + if (anthropicEvent.anthropic?.redactedThinkingData) { + events.push(...closeToolBlocks(context)); + events.push(...closeTextBlock(context)); + events.push(...closeThinkingBlock(context)); + events.push(...ensureRedactedBlockStart( + context, + anthropicEvent.anthropic.redactedThinkingData, + normalizedStartIndex, + )); + } + + if (event.reasoningDelta) { + events.push(...closeRedactedBlock(context)); + events.push(...closeToolBlocks(context)); + events.push(...closeTextBlock(context)); + events.push(...ensureThinkingBlockStart(context, normalizedStartIndex)); + 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(...closeRedactedBlock(context)); + events.push(...closeTextBlock(context)); + events.push(...closeThinkingBlock(context)); + for (const toolDelta of event.toolCallDeltas) { + events.push(...closePreviousToolBlockIfNeeded( + context, + Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0, + )); + 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(...closeRedactedBlock(context)); + events.push(...closeToolBlocks(context)); + events.push(...closeThinkingBlock(context)); + events.push(...ensureTextBlockStart(context, normalizedStartIndex)); + 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 (isMatchingBlockIndex(targetIndex, context.thinkingBlockIndex, context.thinkingSourceIndex)) { + events.push(...closeThinkingBlock(context)); + } + if (isMatchingBlockIndex(targetIndex, context.textBlockIndex, context.textSourceIndex)) { + events.push(...closeTextBlock(context)); + } + if (isMatchingBlockIndex(targetIndex, context.redactedBlockIndex, context.redactedSourceIndex)) { + events.push(...closeRedactedBlock(context)); + } + const matchingToolBlock = Object.values(context.toolBlocks).find((item) => ( + item.open + && isMatchingBlockIndex(targetIndex, item.contentIndex, item.sourceIndex) + )); + if (matchingToolBlock) { + matchingToolBlock.open = false; + if (typeof context.activeToolSlot === 'number') { + const activeState = context.toolBlocks[context.activeToolSlot]; + if (activeState?.contentIndex === matchingToolBlock.contentIndex) { + context.activeToolSlot = null; + } + } + 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/canonical/continuationBridge.test.ts b/src/server/transformers/canonical/continuationBridge.test.ts new file mode 100644 index 0000000..2996efb --- /dev/null +++ b/src/server/transformers/canonical/continuationBridge.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyOpenAiCompatibleContinuation, + buildOpenAiCompatibleMetadataWithContinuation, + normalizeCanonicalContinuation, + OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY, + readOpenAiCompatibleContinuation, +} from './continuationBridge.js'; + +describe('canonical continuation bridge helpers', () => { + it('normalizes canonical continuation fields and drops blanks', () => { + expect(normalizeCanonicalContinuation({ + sessionId: ' session-1 ', + previousResponseId: ' ', + promptCacheKey: ' cache-1 ', + turnState: ' turn-1 ', + })).toEqual({ + sessionId: 'session-1', + promptCacheKey: 'cache-1', + turnState: 'turn-1', + }); + }); + + it('reads openai-compatible continuation fields from top-level fields and metadata namespace', () => { + expect(readOpenAiCompatibleContinuation({ + session_id: 'session-top-level', + conversation_id: 'conversation-top-level', + previous_response_id: 'resp-1', + prompt_cache_key: 'cache-1', + metadata: { + user_id: 'session-metadata', + [OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]: 'turn-state-1', + }, + })).toEqual({ + sessionId: 'session-metadata', + previousResponseId: 'resp-1', + promptCacheKey: 'cache-1', + turnState: 'turn-state-1', + }); + }); + + it('materializes continuation semantics into openai-compatible metadata and body fields', () => { + const body: Record = { + model: 'gpt-5', + }; + + applyOpenAiCompatibleContinuation(body, { + sessionId: 'session-bridge-1', + previousResponseId: 'resp-bridge-1', + promptCacheKey: 'cache-bridge-1', + turnState: 'turn-bridge-1', + }, { existing: true }); + + expect(body).toEqual({ + model: 'gpt-5', + previous_response_id: 'resp-bridge-1', + prompt_cache_key: 'cache-bridge-1', + metadata: { + existing: true, + user_id: 'session-bridge-1', + [OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]: 'turn-bridge-1', + }, + }); + }); + + it('does not overwrite explicit metadata when materializing continuation semantics', () => { + expect(buildOpenAiCompatibleMetadataWithContinuation({ + user_id: 'user-explicit', + [OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]: 'turn-explicit', + }, { + sessionId: 'session-bridge-1', + turnState: 'turn-bridge-1', + })).toEqual({ + user_id: 'user-explicit', + [OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]: 'turn-explicit', + }); + }); +}); diff --git a/src/server/transformers/canonical/continuationBridge.ts b/src/server/transformers/canonical/continuationBridge.ts new file mode 100644 index 0000000..7a53fbc --- /dev/null +++ b/src/server/transformers/canonical/continuationBridge.ts @@ -0,0 +1,94 @@ +import type { CanonicalContinuation } from './types.js'; + +export const OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY = 'metapi_turn_state'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export function normalizeCanonicalContinuation( + continuation: CanonicalContinuation | undefined, +): CanonicalContinuation | undefined { + if (!continuation) return undefined; + + const normalized: CanonicalContinuation = { + ...(asTrimmedString(continuation.sessionId) ? { sessionId: asTrimmedString(continuation.sessionId) } : {}), + ...(asTrimmedString(continuation.previousResponseId) + ? { previousResponseId: asTrimmedString(continuation.previousResponseId) } + : {}), + ...(asTrimmedString(continuation.promptCacheKey) + ? { promptCacheKey: asTrimmedString(continuation.promptCacheKey) } + : {}), + ...(asTrimmedString(continuation.turnState) ? { turnState: asTrimmedString(continuation.turnState) } : {}), + }; + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function readOpenAiCompatibleContinuation( + body: Record, + existing?: CanonicalContinuation, +): CanonicalContinuation | undefined { + const metadata = isRecord(body.metadata) ? body.metadata : null; + return normalizeCanonicalContinuation({ + ...(existing ?? {}), + ...(asTrimmedString(body.session_id) + ? { sessionId: asTrimmedString(body.session_id) } + : {}), + ...(asTrimmedString(body.conversation_id) + ? { sessionId: asTrimmedString(body.conversation_id) } + : {}), + ...(metadata && asTrimmedString(metadata.user_id) + ? { sessionId: asTrimmedString(metadata.user_id) } + : {}), + ...(asTrimmedString(body.previous_response_id) + ? { previousResponseId: asTrimmedString(body.previous_response_id) } + : {}), + ...(asTrimmedString(body.prompt_cache_key) + ? { promptCacheKey: asTrimmedString(body.prompt_cache_key) } + : {}), + ...(metadata && asTrimmedString(metadata[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]) + ? { turnState: asTrimmedString(metadata[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]) } + : {}), + }); +} + +export function buildOpenAiCompatibleMetadataWithContinuation( + metadata: Record, + continuation: CanonicalContinuation | undefined, +): Record { + const normalized = normalizeCanonicalContinuation(continuation); + const nextMetadata: Record = { ...metadata }; + + if (!normalized) return nextMetadata; + if (!('user_id' in nextMetadata) && normalized.sessionId) { + nextMetadata.user_id = normalized.sessionId; + } + if (!(OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY in nextMetadata) && normalized.turnState) { + nextMetadata[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY] = normalized.turnState; + } + return nextMetadata; +} + +export function applyOpenAiCompatibleContinuation( + body: Record, + continuation: CanonicalContinuation | undefined, + metadata: Record = {}, +): void { + const normalized = normalizeCanonicalContinuation(continuation); + const nextMetadata = buildOpenAiCompatibleMetadataWithContinuation(metadata, normalized); + + if (Object.keys(nextMetadata).length > 0) { + body.metadata = nextMetadata; + } + if (normalized?.promptCacheKey) { + body.prompt_cache_key = normalized.promptCacheKey; + } + if (normalized?.previousResponseId) { + body.previous_response_id = normalized.previousResponseId; + } +} diff --git a/src/server/transformers/canonical/envelope.ts b/src/server/transformers/canonical/envelope.ts new file mode 100644 index 0000000..b5fff13 --- /dev/null +++ b/src/server/transformers/canonical/envelope.ts @@ -0,0 +1,77 @@ +import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; +import type { CanonicalAttachment } from './attachments.js'; +import type { + CanonicalCliProfile, + CanonicalContinuation, + CanonicalMessage, + CanonicalOperation, + CanonicalReasoningRequest, + CanonicalRequestEnvelope, + CanonicalSurface, +} from './types.js'; +import { normalizeCanonicalContinuation } from './continuationBridge.js'; + +export type CreateCanonicalRequestEnvelopeInput = { + operation?: CanonicalOperation; + surface: CanonicalSurface; + cliProfile?: CanonicalCliProfile; + requestedModel: string; + stream?: boolean; + messages?: CanonicalMessage[]; + reasoning?: CanonicalReasoningRequest; + tools?: CanonicalTool[]; + toolChoice?: CanonicalToolChoice; + continuation?: CanonicalContinuation; + metadata?: Record; + passthrough?: Record; + attachments?: CanonicalAttachment[]; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + 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; +} + +export function createCanonicalRequestEnvelope( + input: CreateCanonicalRequestEnvelopeInput, +): CanonicalRequestEnvelope { + const requestedModel = asTrimmedString(input.requestedModel); + if (!requestedModel) { + throw new Error('canonical request requires requestedModel'); + } + + return { + operation: input.operation ?? 'generate', + surface: input.surface, + cliProfile: input.cliProfile ?? 'generic', + requestedModel, + stream: input.stream === true, + messages: Array.isArray(input.messages) ? input.messages : [], + ...(input.reasoning ? { reasoning: input.reasoning } : {}), + ...(Array.isArray(input.tools) && input.tools.length > 0 ? { tools: input.tools } : {}), + ...(input.toolChoice !== undefined ? { toolChoice: input.toolChoice } : {}), + ...(normalizeCanonicalContinuation(input.continuation) + ? { continuation: normalizeCanonicalContinuation(input.continuation) } + : {}), + ...(input.metadata ? { metadata: input.metadata } : {}), + ...(input.passthrough ? { passthrough: input.passthrough } : {}), + ...(Array.isArray(input.attachments) && input.attachments.length > 0 + ? { attachments: cloneJsonValue(input.attachments) as CanonicalAttachment[] } + : {}), + }; +} diff --git a/src/server/transformers/canonical/openAiRequestBridge.test.ts b/src/server/transformers/canonical/openAiRequestBridge.test.ts new file mode 100644 index 0000000..d7fafb7 --- /dev/null +++ b/src/server/transformers/canonical/openAiRequestBridge.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalRequestFromOpenAiBody, + canonicalRequestToOpenAiChatBody, +} from './openAiRequestBridge.js'; + +describe('openAiRequestBridge', () => { + it('parses openai-compatible continuation hints into canonical envelopes', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + stream: false, + conversation_id: 'conversation-1', + previous_response_id: 'resp-1', + prompt_cache_key: 'cache-1', + metadata: { + user_id: 'session-1', + metapi_turn_state: 'turn-state-1', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-responses', + }); + + expect(request).toMatchObject({ + continuation: { + sessionId: 'session-1', + previousResponseId: 'resp-1', + promptCacheKey: 'cache-1', + turnState: 'turn-state-1', + }, + }); + }); + + it('builds openai-compatible continuation fields back from canonical envelopes', () => { + const body = canonicalRequestToOpenAiChatBody({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + sessionId: 'session-1', + previousResponseId: 'resp-1', + promptCacheKey: 'cache-1', + turnState: 'turn-state-1', + }, + }); + + expect(body).toMatchObject({ + previous_response_id: 'resp-1', + prompt_cache_key: 'cache-1', + metadata: { + user_id: 'session-1', + metapi_turn_state: 'turn-state-1', + }, + }); + }); +}); diff --git a/src/server/transformers/canonical/openAiRequestBridge.ts b/src/server/transformers/canonical/openAiRequestBridge.ts new file mode 100644 index 0000000..6f604f6 --- /dev/null +++ b/src/server/transformers/canonical/openAiRequestBridge.ts @@ -0,0 +1,564 @@ +import { + canonicalAttachmentFromInputFileBlock, + canonicalAttachmentToNormalizedInputFile, + type CanonicalAttachment, +} from './attachments.js'; +import { createCanonicalRequestEnvelope } from './envelope.js'; +import { + applyOpenAiCompatibleContinuation, + readOpenAiCompatibleContinuation, +} from './continuationBridge.js'; +import { normalizeCanonicalReasoningRequest } from './reasoning.js'; +import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; +import type { + CanonicalContentPart, + CanonicalCliProfile, + CanonicalContinuation, + CanonicalMessage, + CanonicalMessageRole, + CanonicalOperation, + CanonicalRequestEnvelope, + CanonicalSurface, +} from './types.js'; +import { toOpenAiChatFileBlock } from '../shared/inputFile.js'; + +type CanonicalRequestFromOpenAiBodyInput = { + body: Record; + surface: CanonicalSurface; + cliProfile?: CanonicalCliProfile; + operation?: CanonicalOperation; + metadata?: Record; + passthrough?: Record; + continuation?: CanonicalContinuation; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + 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 safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function joinNonEmpty(parts: string[]): string { + return parts + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .join('\n\n'); +} + +function normalizeRole(value: unknown): CanonicalMessageRole { + const role = asTrimmedString(value).toLowerCase(); + switch (role) { + case 'system': + case 'developer': + case 'assistant': + case 'tool': + return role; + default: + return 'user'; + } +} + +function openAiContentToCanonicalParts(content: unknown): CanonicalContentPart[] { + if (typeof content === 'string') { + return content ? [{ type: 'text', text: content }] : []; + } + + if (!Array.isArray(content)) return []; + + const parts: CanonicalContentPart[] = []; + for (const item of content) { + if (typeof item === 'string') { + if (item) parts.push({ type: 'text', text: item }); + continue; + } + if (!isRecord(item)) continue; + + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'text' || type === 'input_text' || type === 'output_text') { + const text = asTrimmedString(item.text); + if (text) parts.push({ type: 'text', text }); + continue; + } + if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') { + const text = asTrimmedString(item.text ?? item.reasoning ?? item.thinking); + if (text) parts.push({ type: 'text', text, thought: true }); + continue; + } + if (type === 'image_url' && isRecord(item.image_url)) { + const url = asTrimmedString(item.image_url.url); + if (url) parts.push({ type: 'image', url }); + continue; + } + if (type === 'input_image' && isRecord(item.image_url)) { + const url = asTrimmedString(item.image_url.url); + if (url) parts.push({ type: 'image', url }); + continue; + } + if (type === 'input_file' || type === 'file') { + const attachment = canonicalAttachmentFromInputFileBlock(item); + if (attachment) { + parts.push({ + type: 'file', + ...(attachment.fileId ? { fileId: attachment.fileId } : {}), + ...(attachment.fileUrl ? { fileUrl: attachment.fileUrl } : {}), + ...(attachment.fileData ? { fileData: attachment.fileData } : {}), + ...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}), + ...(attachment.filename ? { filename: attachment.filename } : {}), + }); + } + continue; + } + } + + return parts; +} + +function appendAssistantReasoningPart( + parts: CanonicalContentPart[], + rawMessage: Record, +): void { + const directReasoning = joinNonEmpty([ + asTrimmedString(rawMessage.reasoning_content), + asTrimmedString(rawMessage.reasoning), + ]); + if (!directReasoning) return; + + const alreadyPresent = parts.some((part) => ( + part.type === 'text' + && part.thought === true + && part.text === directReasoning + )); + if (alreadyPresent) return; + + parts.unshift({ + type: 'text', + text: directReasoning, + thought: true, + }); +} + +function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefined { + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized; + if (normalized === 'any') return 'required'; + return rawToolChoice.trim() ? { type: 'raw', value: rawToolChoice } : undefined; + } + + if (!isRecord(rawToolChoice)) return undefined; + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'auto' || type === 'none') return type; + if (type === 'any' || type === 'required') return 'required'; + if (type === 'function') { + const name = asTrimmedString( + (isRecord(rawToolChoice.function) ? rawToolChoice.function.name : undefined) + ?? rawToolChoice.name, + ); + return name ? { type: 'tool', name } : undefined; + } + if (type && type !== 'tool') { + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; + } + + const name = asTrimmedString( + rawToolChoice.name + ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), + ); + const toolChoiceKeys = Object.keys(rawToolChoice); + const hasExtraToolFields = toolChoiceKeys.some((key) => key !== 'type' && key !== 'name' && key !== 'tool'); + if (hasExtraToolFields) { + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; + } + if (name) return { type: 'tool', name }; + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; +} + +function parseTools(rawTools: unknown): CanonicalTool[] | undefined { + if (!Array.isArray(rawTools)) return undefined; + + const tools: CanonicalTool[] = rawTools + .flatMap((item): CanonicalTool[] => { + if (!isRecord(item)) return []; + const itemType = asTrimmedString(item.type).toLowerCase(); + + if (itemType === 'function' && isRecord(item.function)) { + const name = asTrimmedString(item.function.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(item.function.description) + ? { description: asTrimmedString(item.function.description) } + : {}), + ...(typeof item.function.strict === 'boolean' ? { strict: item.function.strict } : {}), + ...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}), + }]; + } + + if ((itemType === '' || itemType === 'tool') && asTrimmedString(item.name)) { + return [{ + name: asTrimmedString(item.name), + ...(asTrimmedString(item.description) + ? { description: asTrimmedString(item.description) } + : {}), + ...(typeof item.strict === 'boolean' ? { strict: item.strict } : {}), + ...(isRecord(item.input_schema) + ? { inputSchema: cloneJsonValue(item.input_schema) } + : (isRecord(item.inputSchema) ? { inputSchema: cloneJsonValue(item.inputSchema) } : {})), + }]; + } + + if (Array.isArray(item.functionDeclarations)) { + return item.functionDeclarations.flatMap((declaration) => { + if (!isRecord(declaration)) return []; + const name = asTrimmedString(declaration.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(declaration.description) + ? { description: asTrimmedString(declaration.description) } + : {}), + ...(isRecord(declaration.parametersJsonSchema) + ? { inputSchema: cloneJsonValue(declaration.parametersJsonSchema) } + : (isRecord(declaration.parameters) ? { inputSchema: cloneJsonValue(declaration.parameters) } : {})), + }]; + }); + } + + if (itemType) { + return [{ + type: itemType, + raw: cloneJsonValue(item) as Record, + }]; + } + + return []; + }); + + return tools.length > 0 ? tools : undefined; +} + +export function canonicalRequestFromOpenAiBody( + input: CanonicalRequestFromOpenAiBodyInput, +): CanonicalRequestEnvelope { + const body = input.body; + const metadata = isRecord(input.metadata) + ? input.metadata + : (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined); + const attachments = Array.isArray(body.attachments) + ? cloneJsonValue(body.attachments) as CanonicalAttachment[] + : undefined; + const messages: CanonicalMessage[] = []; + const rawMessages = Array.isArray(body.messages) ? body.messages : []; + + for (const rawMessage of rawMessages) { + if (!isRecord(rawMessage)) continue; + const role = normalizeRole(rawMessage.role); + + if (role === 'tool') { + const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id); + const rawContent = rawMessage.content; + const resultText = typeof rawContent === 'string' + ? rawContent + : (!Array.isArray(rawContent) && !isRecord(rawContent) ? safeJsonStringify(rawContent ?? '') : ''); + messages.push({ + role: 'tool', + parts: [{ + type: 'tool_result', + toolCallId: toolCallId || 'tool', + ...(resultText ? { resultText } : {}), + ...(Array.isArray(rawContent) + ? { resultContent: cloneJsonValue(rawContent) as Array> } + : (isRecord(rawContent) + ? { resultContent: [cloneJsonValue(rawContent) as Record] } + : {})), + }], + }); + continue; + } + + const parts = openAiContentToCanonicalParts(rawMessage.content); + if (role === 'assistant') { + appendAssistantReasoningPart(parts, rawMessage); + } + const toolCalls = Array.isArray(rawMessage.tool_calls) ? rawMessage.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall)) continue; + const fn = isRecord(toolCall.function) ? toolCall.function : {}; + const id = asTrimmedString(toolCall.id); + const name = asTrimmedString(toolCall.name ?? fn.name); + const argumentsJson = typeof fn.arguments === 'string' + ? fn.arguments + : safeJsonStringify(fn.arguments ?? toolCall.arguments ?? {}); + if (!name) continue; + parts.push({ + type: 'tool_call', + id: id || `tool_${parts.length}`, + name, + argumentsJson, + }); + } + + messages.push({ + role, + parts, + ...(asTrimmedString(rawMessage.phase) ? { phase: asTrimmedString(rawMessage.phase) } : {}), + ...(asTrimmedString(rawMessage.reasoning_signature) + ? { reasoningSignature: asTrimmedString(rawMessage.reasoning_signature) } + : {}), + }); + } + + const reasoningResult = normalizeCanonicalReasoningRequest({ + include: body.include, + reasoning: body.reasoning, + reasoning_effort: body.reasoning_effort, + reasoning_budget: body.reasoning_budget, + reasoning_summary: body.reasoning_summary, + }); + + const continuation = readOpenAiCompatibleContinuation(body, input.continuation); + + const passthrough = { + ...(input.passthrough ?? {}), + ...(typeof body.parallel_tool_calls === 'boolean' + ? { parallel_tool_calls: body.parallel_tool_calls } + : {}), + ...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}), + }; + + return createCanonicalRequestEnvelope({ + operation: input.operation ?? 'generate', + surface: input.surface, + cliProfile: input.cliProfile ?? 'generic', + requestedModel: asTrimmedString(body.model), + stream: body.stream === true, + messages, + ...(reasoningResult.reasoning ? { reasoning: reasoningResult.reasoning } : {}), + ...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}), + ...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}), + ...(continuation ? { continuation } : {}), + ...(metadata ? { metadata } : {}), + ...(attachments ? { attachments } : {}), + ...(Object.keys(passthrough).length > 0 ? { passthrough } : {}), + }); +} + +function canonicalPartsToOpenAiContent( + role: CanonicalMessageRole, + parts: CanonicalContentPart[], +): { content: string | Array>; reasoning?: string; toolCalls?: Array> } { + const contentBlocks: Array> = []; + const toolCalls: Array> = []; + const visibleText: string[] = []; + const reasoningText: string[] = []; + + for (const part of parts) { + if (part.type === 'text') { + if (part.thought === true) { + reasoningText.push(part.text); + } else { + visibleText.push(part.text); + } + continue; + } + if (part.type === 'image') { + const url = asTrimmedString(part.url ?? part.dataUrl); + if (url) { + contentBlocks.push({ + type: 'image_url', + image_url: { url }, + }); + } + continue; + } + if (part.type === 'file') { + const normalizedFile = canonicalAttachmentToNormalizedInputFile({ + kind: 'file', + ...(part.fileId ? { fileId: part.fileId } : {}), + ...(part.fileUrl ? { fileUrl: part.fileUrl } : {}), + ...(part.fileData ? { fileData: part.fileData } : {}), + ...(part.filename ? { filename: part.filename } : {}), + ...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}), + }); + contentBlocks.push(toOpenAiChatFileBlock(normalizedFile)); + continue; + } + if (part.type === 'tool_call') { + toolCalls.push({ + id: part.id, + type: 'function', + function: { + name: part.name, + arguments: part.argumentsJson, + }, + }); + continue; + } + if (part.type === 'tool_result' && role !== 'tool') { + const text = part.resultText + ?? (typeof part.resultContent === 'string' + ? part.resultContent + : safeJsonStringify(part.resultJson ?? part.resultContent ?? '')); + if (text) { + visibleText.push(text); + } + } + } + + if (contentBlocks.length <= 0) { + return { + content: visibleText.join(''), + ...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }; + } + + if (visibleText.length > 0) { + contentBlocks.unshift({ + type: 'text', + text: visibleText.join(''), + }); + } + + return { + content: contentBlocks, + ...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }; +} + +function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined): unknown { + if (!toolChoice) return undefined; + if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice; + if (toolChoice === 'required') return 'required'; + if (toolChoice.type === 'raw') return cloneJsonValue(toolChoice.value); + return { + type: 'function', + function: { + name: toolChoice.name, + }, + }; +} + +export function canonicalRequestToOpenAiChatBody( + request: CanonicalRequestEnvelope, +): Record { + const messages: Array> = []; + + for (const message of request.messages) { + if (message.role === 'tool') { + for (const part of message.parts) { + if (part.type !== 'tool_result') continue; + messages.push({ + role: 'tool', + tool_call_id: part.toolCallId, + content: part.resultContent + ?? part.resultText + ?? safeJsonStringify(part.resultJson ?? ''), + }); + } + continue; + } + + const converted = canonicalPartsToOpenAiContent(message.role, message.parts); + const nextMessage: Record = { + role: message.role, + content: converted.content, + }; + if (message.role === 'assistant' && converted.reasoning) { + nextMessage.reasoning_content = converted.reasoning; + } + if (message.phase) nextMessage.phase = message.phase; + if (message.reasoningSignature) nextMessage.reasoning_signature = message.reasoningSignature; + if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) { + nextMessage.tool_calls = converted.toolCalls; + if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array).length <= 0) { + nextMessage.content = ''; + } + } + messages.push(nextMessage); + } + + const body: Record = { + model: request.requestedModel, + stream: request.stream, + messages, + }; + + if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort; + if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens; + if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary; + const transformerMetadata = isRecord(request.passthrough?.transformerMetadata) + ? request.passthrough.transformerMetadata as Record + : null; + const passthroughInclude = Array.isArray(transformerMetadata?.include) + ? (transformerMetadata.include as unknown[]) + .filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()) + : []; + const mergedInclude = [ + ...(request.reasoning?.includeEncryptedContent ? ['reasoning.encrypted_content'] : []), + ...passthroughInclude, + ].filter((item, index, all) => all.indexOf(item) === index); + if (mergedInclude.length > 0) body.include = mergedInclude; + const metadata = isRecord(request.metadata) + ? cloneJsonValue(request.metadata) + : {}; + applyOpenAiCompatibleContinuation(body, request.continuation, metadata); + if (Array.isArray(request.attachments) && request.attachments.length > 0) { + body.attachments = cloneJsonValue(request.attachments); + } + if (Array.isArray(request.tools) && request.tools.length > 0) { + body.tools = request.tools.map((tool) => { + if ('raw' in tool) { + const raw = cloneJsonValue(tool.raw) as Record; + if (typeof raw.type !== 'string' || raw.type.trim().length === 0) { + raw.type = tool.type; + } + return raw; + } + return { + type: 'function', + function: { + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}), + parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }), + }, + }; + }); + } + const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice); + if (toolChoice !== undefined) body.tool_choice = toolChoice; + + if (isRecord(request.passthrough)) { + for (const [key, value] of Object.entries(request.passthrough)) { + if (key === 'transformerMetadata' || body[key] !== undefined) continue; + body[key] = cloneJsonValue(value); + } + } + + return body; +} diff --git a/src/server/transformers/canonical/request.test.ts b/src/server/transformers/canonical/request.test.ts index 0d810fb..fb81979 100644 --- a/src/server/transformers/canonical/request.test.ts +++ b/src/server/transformers/canonical/request.test.ts @@ -103,6 +103,98 @@ describe('canonical request helpers', () => { }); }); + it('collects continuation session ids from OpenAI-compatible metadata and custom session fields', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + stream: false, + session_id: 'session-body-1', + conversation_id: 'conversation-body-1', + metadata: { + user_id: 'session-metadata-1', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-chat', + }); + + expect(request).toMatchObject({ + continuation: { + sessionId: 'session-metadata-1', + }, + }); + }); + + it('collects continuation turnState from OpenAI-compatible metadata namespace', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + stream: false, + metadata: { + metapi_turn_state: 'turn-state-1', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-chat', + }); + + expect(request).toMatchObject({ + continuation: { + turnState: 'turn-state-1', + }, + }); + }); + + it('materializes continuation session ids into metadata.user_id without overwriting explicit metadata', () => { + const body = canonicalRequestToOpenAiChatBody({ + operation: 'generate', + surface: 'anthropic-messages', + cliProfile: 'claude_code', + requestedModel: 'claude-sonnet-4-5', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + sessionId: 'session-bridge-1', + promptCacheKey: 'cache-1', + }, + metadata: { + existing: true, + }, + }); + + expect(body).toMatchObject({ + metadata: { + existing: true, + user_id: 'session-bridge-1', + }, + prompt_cache_key: 'cache-1', + }); + }); + + it('materializes continuation turnState into metadata without overwriting explicit metadata', () => { + const body = canonicalRequestToOpenAiChatBody({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: true, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + turnState: 'turn-state-2', + }, + metadata: { + existing: true, + }, + }); + + expect(body).toMatchObject({ + metadata: { + existing: true, + metapi_turn_state: 'turn-state-2', + }, + }); + }); + it('parses anthropic-shaped tools from compatibility bodies', () => { const request = canonicalRequestFromOpenAiBody({ body: { diff --git a/src/server/transformers/canonical/request.ts b/src/server/transformers/canonical/request.ts index 701a667..a6bee79 100644 --- a/src/server/transformers/canonical/request.ts +++ b/src/server/transformers/canonical/request.ts @@ -1,602 +1,9 @@ -import { - canonicalAttachmentFromInputFileBlock, - canonicalAttachmentToNormalizedInputFile, - type CanonicalAttachment, -} from './attachments.js'; -import { normalizeCanonicalReasoningRequest } from './reasoning.js'; -import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; -import type { - CanonicalContentPart, - CanonicalCliProfile, - CanonicalContinuation, - CanonicalMessage, - CanonicalMessageRole, - CanonicalOperation, - CanonicalReasoningRequest, - CanonicalRequestEnvelope, - CanonicalSurface, -} from './types.js'; -import { toOpenAiChatFileBlock } from '../shared/inputFile.js'; - -export type CreateCanonicalRequestEnvelopeInput = { - operation?: CanonicalOperation; - surface: CanonicalSurface; - cliProfile?: CanonicalCliProfile; - requestedModel: string; - stream?: boolean; - messages?: CanonicalMessage[]; - reasoning?: CanonicalReasoningRequest; - tools?: CanonicalTool[]; - toolChoice?: CanonicalToolChoice; - continuation?: CanonicalContinuation; - metadata?: Record; - passthrough?: Record; - attachments?: CanonicalAttachment[]; -}; - -function asTrimmedString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function isRecord(value: unknown): value is Record { - 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 safeJsonStringify(value: unknown): string { - try { - return JSON.stringify(value); - } catch { - return ''; - } -} - -function normalizeCanonicalContinuation( - continuation: CanonicalContinuation | undefined, -): CanonicalContinuation | undefined { - if (!continuation) return undefined; - - const normalized: CanonicalContinuation = { - ...(asTrimmedString(continuation.sessionId) ? { sessionId: asTrimmedString(continuation.sessionId) } : {}), - ...(asTrimmedString(continuation.previousResponseId) - ? { previousResponseId: asTrimmedString(continuation.previousResponseId) } - : {}), - ...(asTrimmedString(continuation.promptCacheKey) - ? { promptCacheKey: asTrimmedString(continuation.promptCacheKey) } - : {}), - ...(asTrimmedString(continuation.turnState) ? { turnState: asTrimmedString(continuation.turnState) } : {}), - }; - - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -export function createCanonicalRequestEnvelope( - input: CreateCanonicalRequestEnvelopeInput, -): CanonicalRequestEnvelope { - const requestedModel = asTrimmedString(input.requestedModel); - if (!requestedModel) { - throw new Error('canonical request requires requestedModel'); - } - - return { - operation: input.operation ?? 'generate', - surface: input.surface, - cliProfile: input.cliProfile ?? 'generic', - requestedModel, - stream: input.stream === true, - messages: Array.isArray(input.messages) ? input.messages : [], - ...(input.reasoning ? { reasoning: input.reasoning } : {}), - ...(Array.isArray(input.tools) && input.tools.length > 0 ? { tools: input.tools } : {}), - ...(input.toolChoice !== undefined ? { toolChoice: input.toolChoice } : {}), - ...(normalizeCanonicalContinuation(input.continuation) - ? { continuation: normalizeCanonicalContinuation(input.continuation) } - : {}), - ...(input.metadata ? { metadata: input.metadata } : {}), - ...(input.passthrough ? { passthrough: input.passthrough } : {}), - ...(Array.isArray(input.attachments) && input.attachments.length > 0 - ? { attachments: cloneJsonValue(input.attachments) as CanonicalAttachment[] } - : {}), - }; -} - -type CanonicalRequestFromOpenAiBodyInput = { - body: Record; - surface: CanonicalSurface; - cliProfile?: CanonicalCliProfile; - operation?: CanonicalOperation; - metadata?: Record; - passthrough?: Record; - continuation?: CanonicalContinuation; -}; - -function normalizeRole(value: unknown): CanonicalMessageRole { - const role = asTrimmedString(value).toLowerCase(); - switch (role) { - case 'system': - case 'developer': - case 'assistant': - case 'tool': - return role; - default: - return 'user'; - } -} - -function openAiContentToCanonicalParts(content: unknown): CanonicalContentPart[] { - if (typeof content === 'string') { - return content ? [{ type: 'text', text: content }] : []; - } - - if (!Array.isArray(content)) return []; - - const parts: CanonicalContentPart[] = []; - for (const item of content) { - if (typeof item === 'string') { - if (item) parts.push({ type: 'text', text: item }); - continue; - } - if (!isRecord(item)) continue; - - const type = asTrimmedString(item.type).toLowerCase(); - if (type === 'text' || type === 'input_text' || type === 'output_text') { - const text = asTrimmedString(item.text); - if (text) parts.push({ type: 'text', text }); - continue; - } - if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') { - const text = asTrimmedString(item.text ?? item.reasoning ?? item.thinking); - if (text) parts.push({ type: 'text', text, thought: true }); - continue; - } - if (type === 'image_url' && isRecord(item.image_url)) { - const url = asTrimmedString(item.image_url.url); - if (url) parts.push({ type: 'image', url }); - continue; - } - if (type === 'input_image' && isRecord(item.image_url)) { - const url = asTrimmedString(item.image_url.url); - if (url) parts.push({ type: 'image', url }); - continue; - } - if (type === 'input_file' || type === 'file') { - const attachment = canonicalAttachmentFromInputFileBlock(item); - if (attachment) { - parts.push({ - type: 'file', - ...(attachment.fileId ? { fileId: attachment.fileId } : {}), - ...(attachment.fileUrl ? { fileUrl: attachment.fileUrl } : {}), - ...(attachment.fileData ? { fileData: attachment.fileData } : {}), - ...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}), - ...(attachment.filename ? { filename: attachment.filename } : {}), - }); - } - continue; - } - } - - return parts; -} - -function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefined { - if (typeof rawToolChoice === 'string') { - const normalized = rawToolChoice.trim().toLowerCase(); - if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized; - if (normalized === 'any') return 'required'; - return rawToolChoice.trim() ? { type: 'raw', value: rawToolChoice } : undefined; - } - - if (!isRecord(rawToolChoice)) return undefined; - const type = asTrimmedString(rawToolChoice.type).toLowerCase(); - if (type === 'auto' || type === 'none') return type; - if (type === 'any' || type === 'required') return 'required'; - if (type === 'function') { - const name = asTrimmedString( - (isRecord(rawToolChoice.function) ? rawToolChoice.function.name : undefined) - ?? rawToolChoice.name, - ); - return name ? { type: 'tool', name } : undefined; - } - if (type && type !== 'tool') { - return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; - } - - const name = asTrimmedString( - rawToolChoice.name - ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), - ); - const toolChoiceKeys = Object.keys(rawToolChoice); - const hasExtraToolFields = toolChoiceKeys.some((key) => key !== 'type' && key !== 'name' && key !== 'tool'); - if (hasExtraToolFields) { - return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; - } - if (name) return { type: 'tool', name }; - return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; -} - -function parseTools(rawTools: unknown): CanonicalTool[] | undefined { - if (!Array.isArray(rawTools)) return undefined; - - const tools: CanonicalTool[] = rawTools - .flatMap((item): CanonicalTool[] => { - if (!isRecord(item)) return []; - const itemType = asTrimmedString(item.type).toLowerCase(); - - if (itemType === 'function' && isRecord(item.function)) { - const name = asTrimmedString(item.function.name); - if (!name) return []; - return [{ - name, - ...(asTrimmedString(item.function.description) - ? { description: asTrimmedString(item.function.description) } - : {}), - ...(typeof item.function.strict === 'boolean' ? { strict: item.function.strict } : {}), - ...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}), - }]; - } - - if ((itemType === '' || itemType === 'tool') && asTrimmedString(item.name)) { - return [{ - name: asTrimmedString(item.name), - ...(asTrimmedString(item.description) - ? { description: asTrimmedString(item.description) } - : {}), - ...(typeof item.strict === 'boolean' ? { strict: item.strict } : {}), - ...(isRecord(item.input_schema) - ? { inputSchema: cloneJsonValue(item.input_schema) } - : (isRecord(item.inputSchema) ? { inputSchema: cloneJsonValue(item.inputSchema) } : {})), - }]; - } - - if (Array.isArray(item.functionDeclarations)) { - return item.functionDeclarations.flatMap((declaration) => { - if (!isRecord(declaration)) return []; - const name = asTrimmedString(declaration.name); - if (!name) return []; - return [{ - name, - ...(asTrimmedString(declaration.description) - ? { description: asTrimmedString(declaration.description) } - : {}), - ...(isRecord(declaration.parametersJsonSchema) - ? { inputSchema: cloneJsonValue(declaration.parametersJsonSchema) } - : (isRecord(declaration.parameters) ? { inputSchema: cloneJsonValue(declaration.parameters) } : {})), - }]; - }); - } - - if (itemType) { - return [{ - type: itemType, - raw: cloneJsonValue(item) as Record, - }]; - } - - return []; - }); - - return tools.length > 0 ? tools : undefined; -} - -export function canonicalRequestFromOpenAiBody( - input: CanonicalRequestFromOpenAiBodyInput, -): CanonicalRequestEnvelope { - const body = input.body; - const metadata = isRecord(input.metadata) - ? input.metadata - : (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined); - const attachments = Array.isArray(body.attachments) - ? cloneJsonValue(body.attachments) as CanonicalAttachment[] - : undefined; - const messages: CanonicalMessage[] = []; - const rawMessages = Array.isArray(body.messages) ? body.messages : []; - - for (const rawMessage of rawMessages) { - if (!isRecord(rawMessage)) continue; - const role = normalizeRole(rawMessage.role); - - if (role === 'tool') { - const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id); - const rawContent = rawMessage.content; - const resultText = typeof rawContent === 'string' - ? rawContent - : (!Array.isArray(rawContent) && !isRecord(rawContent) ? safeJsonStringify(rawContent ?? '') : ''); - messages.push({ - role: 'tool', - parts: [{ - type: 'tool_result', - toolCallId: toolCallId || 'tool', - ...(resultText ? { resultText } : {}), - ...(Array.isArray(rawContent) - ? { resultContent: cloneJsonValue(rawContent) as Array> } - : (isRecord(rawContent) - ? { resultContent: [cloneJsonValue(rawContent) as Record] } - : {})), - }], - }); - continue; - } - - const parts = openAiContentToCanonicalParts(rawMessage.content); - const toolCalls = Array.isArray(rawMessage.tool_calls) ? rawMessage.tool_calls : []; - for (const toolCall of toolCalls) { - if (!isRecord(toolCall)) continue; - const fn = isRecord(toolCall.function) ? toolCall.function : {}; - const id = asTrimmedString(toolCall.id); - const name = asTrimmedString(toolCall.name ?? fn.name); - const argumentsJson = typeof fn.arguments === 'string' - ? fn.arguments - : safeJsonStringify(fn.arguments ?? toolCall.arguments ?? {}); - if (!name) continue; - parts.push({ - type: 'tool_call', - id: id || `tool_${parts.length}`, - name, - argumentsJson, - }); - } - - messages.push({ - role, - parts, - ...(asTrimmedString(rawMessage.phase) ? { phase: asTrimmedString(rawMessage.phase) } : {}), - ...(asTrimmedString(rawMessage.reasoning_signature) - ? { reasoningSignature: asTrimmedString(rawMessage.reasoning_signature) } - : {}), - }); - } - - const reasoningResult = normalizeCanonicalReasoningRequest({ - include: body.include, - reasoning: body.reasoning, - reasoning_effort: body.reasoning_effort, - reasoning_budget: body.reasoning_budget, - reasoning_summary: body.reasoning_summary, - }); - - const continuation: CanonicalContinuation = { - ...(normalizeCanonicalContinuation(input.continuation) ?? {}), - ...(asTrimmedString(body.previous_response_id) - ? { previousResponseId: asTrimmedString(body.previous_response_id) } - : {}), - ...(asTrimmedString(body.prompt_cache_key) - ? { promptCacheKey: asTrimmedString(body.prompt_cache_key) } - : {}), - }; - - const passthrough = { - ...(input.passthrough ?? {}), - ...(typeof body.parallel_tool_calls === 'boolean' - ? { parallel_tool_calls: body.parallel_tool_calls } - : {}), - ...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}), - }; - - return createCanonicalRequestEnvelope({ - operation: input.operation ?? 'generate', - surface: input.surface, - cliProfile: input.cliProfile ?? 'generic', - requestedModel: asTrimmedString(body.model), - stream: body.stream === true, - messages, - ...(reasoningResult.reasoning ? { reasoning: reasoningResult.reasoning } : {}), - ...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}), - ...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}), - ...(Object.keys(continuation).length > 0 ? { continuation } : {}), - ...(metadata ? { metadata } : {}), - ...(attachments ? { attachments } : {}), - ...(Object.keys(passthrough).length > 0 ? { passthrough } : {}), - }); -} - -function canonicalPartsToOpenAiContent( - role: CanonicalMessageRole, - parts: CanonicalContentPart[], -): { content: string | Array>; reasoning?: string; toolCalls?: Array> } { - const contentBlocks: Array> = []; - const toolCalls: Array> = []; - const visibleText: string[] = []; - const reasoningText: string[] = []; - - for (const part of parts) { - if (part.type === 'text') { - if (part.thought === true) { - reasoningText.push(part.text); - } else { - visibleText.push(part.text); - } - continue; - } - if (part.type === 'image') { - const url = asTrimmedString(part.url ?? part.dataUrl); - if (url) { - contentBlocks.push({ - type: 'image_url', - image_url: { url }, - }); - } - continue; - } - if (part.type === 'file') { - const normalizedFile = canonicalAttachmentToNormalizedInputFile({ - kind: 'file', - ...(part.fileId ? { fileId: part.fileId } : {}), - ...(part.fileUrl ? { fileUrl: part.fileUrl } : {}), - ...(part.fileData ? { fileData: part.fileData } : {}), - ...(part.filename ? { filename: part.filename } : {}), - ...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}), - }); - contentBlocks.push(toOpenAiChatFileBlock(normalizedFile)); - continue; - } - if (part.type === 'tool_call') { - toolCalls.push({ - id: part.id, - type: 'function', - function: { - name: part.name, - arguments: part.argumentsJson, - }, - }); - continue; - } - if (part.type === 'tool_result' && role !== 'tool') { - const text = part.resultText - ?? (typeof part.resultContent === 'string' - ? part.resultContent - : safeJsonStringify(part.resultJson ?? part.resultContent ?? '')); - if (text) { - visibleText.push(text); - } - } - } - - if (contentBlocks.length <= 0) { - return { - content: visibleText.join(''), - ...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}), - ...(toolCalls.length > 0 ? { toolCalls } : {}), - }; - } - - if (visibleText.length > 0) { - contentBlocks.unshift({ - type: 'text', - text: visibleText.join(''), - }); - } - - return { - content: contentBlocks, - ...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}), - ...(toolCalls.length > 0 ? { toolCalls } : {}), - }; -} - -function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined): unknown { - if (!toolChoice) return undefined; - if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice; - if (toolChoice === 'required') return 'required'; - if (toolChoice.type === 'raw') return cloneJsonValue(toolChoice.value); - return { - type: 'function', - function: { - name: toolChoice.name, - }, - }; -} - -export function canonicalRequestToOpenAiChatBody( - request: CanonicalRequestEnvelope, -): Record { - const messages: Array> = []; - - for (const message of request.messages) { - if (message.role === 'tool') { - for (const part of message.parts) { - if (part.type !== 'tool_result') continue; - messages.push({ - role: 'tool', - tool_call_id: part.toolCallId, - content: part.resultContent - ?? part.resultText - ?? safeJsonStringify(part.resultJson ?? ''), - }); - } - continue; - } - - const converted = canonicalPartsToOpenAiContent(message.role, message.parts); - const nextMessage: Record = { - role: message.role, - content: converted.content, - }; - if (message.role === 'assistant' && converted.reasoning) { - nextMessage.reasoning_content = converted.reasoning; - } - if (message.phase) nextMessage.phase = message.phase; - if (message.reasoningSignature) nextMessage.reasoning_signature = message.reasoningSignature; - if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) { - nextMessage.tool_calls = converted.toolCalls; - if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array).length <= 0) { - nextMessage.content = ''; - } - } - messages.push(nextMessage); - } - - const body: Record = { - model: request.requestedModel, - stream: request.stream, - messages, - }; - - if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort; - if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens; - if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary; - const transformerMetadata = isRecord(request.passthrough?.transformerMetadata) - ? request.passthrough.transformerMetadata as Record - : null; - const passthroughInclude = Array.isArray(transformerMetadata?.include) - ? (transformerMetadata.include as unknown[]) - .filter((item): item is string => typeof item === 'string' && item.trim().length > 0) - .map((item) => item.trim()) - : []; - const mergedInclude = [ - ...(request.reasoning?.includeEncryptedContent ? ['reasoning.encrypted_content'] : []), - ...passthroughInclude, - ].filter((item, index, all) => all.indexOf(item) === index); - if (mergedInclude.length > 0) body.include = mergedInclude; - if (request.metadata !== undefined) body.metadata = cloneJsonValue(request.metadata); - if (Array.isArray(request.attachments) && request.attachments.length > 0) { - body.attachments = cloneJsonValue(request.attachments); - } - if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey; - if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId; - if (Array.isArray(request.tools) && request.tools.length > 0) { - body.tools = request.tools.map((tool) => { - if ('raw' in tool) { - const raw = cloneJsonValue(tool.raw) as Record; - if (typeof raw.type !== 'string' || raw.type.trim().length === 0) { - raw.type = tool.type; - } - return raw; - } - return { - type: 'function', - function: { - name: tool.name, - ...(tool.description ? { description: tool.description } : {}), - ...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}), - parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }), - }, - }; - }); - } - const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice); - if (toolChoice !== undefined) body.tool_choice = toolChoice; - - if (isRecord(request.passthrough)) { - for (const [key, value] of Object.entries(request.passthrough)) { - if (key === 'transformerMetadata' || body[key] !== undefined) continue; - body[key] = cloneJsonValue(value); - } - } - - return body; -} - +export { createCanonicalRequestEnvelope } from './envelope.js'; +export { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from './openAiRequestBridge.js'; export * from './attachments.js'; +export * from './continuationBridge.js'; +export * from './envelope.js'; +export * from './openAiRequestBridge.js'; export * from './reasoning.js'; export * from './tools.js'; export * from './types.js'; diff --git a/src/server/transformers/gemini/generate-content/cliBridge.test.ts b/src/server/transformers/gemini/generate-content/cliBridge.test.ts new file mode 100644 index 0000000..ee3e0a9 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/cliBridge.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, + wrapGeminiCliRequest, +} from './cliBridge.js'; + +describe('gemini cli bridge', () => { + it('wraps Gemini CLI requests with project and stripped request model', () => { + expect(wrapGeminiCliRequest({ + modelName: 'models/gemini-2.5-pro', + projectId: 'project-123', + request: { + model: 'ignored-model', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + })).toEqual({ + project: 'project-123', + model: 'models/gemini-2.5-pro', + request: { + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + }); + }); + + it('unwraps Gemini CLI payload envelopes to the response body', () => { + expect(unwrapGeminiCliPayload({ response: { ok: true }, project: 'p' })).toEqual({ ok: true }); + expect(unwrapGeminiCliPayload({ ok: true })).toEqual({ ok: true }); + expect(unwrapGeminiCliPayload('raw')).toBe('raw'); + }); + + it('rewrites SSE data blocks to the inner Gemini CLI response payload', async () => { + const chunks = [ + Buffer.from('data: {"response":{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}}\n\n'), + Buffer.from('data: [DONE]\n\n'), + ]; + let index = 0; + const reader = createGeminiCliStreamReader({ + async read() { + if (index >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: new Uint8Array(chunks[index++]) }; + }, + async cancel() { + return undefined; + }, + releaseLock() {}, + }); + + const first = await reader.read(); + const second = await reader.read(); + const third = await reader.read(); + + expect(Buffer.from(first.value ?? []).toString('utf8')).toContain('data: {"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}'); + expect(Buffer.from(second.value ?? []).toString('utf8')).toBe('data: [DONE]\n\n'); + expect(third.done).toBe(true); + }); +}); diff --git a/src/server/transformers/gemini/generate-content/cliBridge.ts b/src/server/transformers/gemini/generate-content/cliBridge.ts new file mode 100644 index 0000000..c6a51c6 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/cliBridge.ts @@ -0,0 +1,94 @@ +import { TextDecoder, TextEncoder } from 'node:util'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export function wrapGeminiCliRequest(input: { + modelName: string; + projectId: string; + request: Record; +}) { + const { model: _model, ...requestPayload } = input.request; + return { + project: input.projectId, + model: input.modelName, + request: requestPayload, + }; +} + +export function unwrapGeminiCliPayload(payload: T): unknown { + if (!isRecord(payload)) return payload; + if (payload.response !== undefined) { + return payload.response; + } + return payload; +} + +function rewriteGeminiCliSseEventBlock(block: string): string { + const lines = block.split(/\r?\n/g); + return lines.map((line) => { + if (!line.startsWith('data:')) return line; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') return line; + try { + const parsed = JSON.parse(data); + return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`; + } catch { + return line; + } + }).join('\n'); +} + +export function createGeminiCliStreamReader(reader: { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}) { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const outputQueue: Uint8Array[] = []; + let buffer = ''; + let done = false; + + async function fillQueue() { + while (outputQueue.length <= 0 && !done) { + const result = await reader.read(); + if (result.done) { + done = true; + const tail = decoder.decode(); + if (tail) buffer += tail; + if (buffer.trim()) { + outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`)); + buffer = ''; + } + break; + } + if (!result.value) continue; + buffer += decoder.decode(result.value, { stream: true }); + let separatorIndex = buffer.indexOf('\n\n'); + while (separatorIndex >= 0) { + const block = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`)); + separatorIndex = buffer.indexOf('\n\n'); + } + } + } + + return { + async read() { + await fillQueue(); + if (outputQueue.length > 0) { + return { done: false, value: outputQueue.shift() }; + } + return { done: true, value: undefined }; + }, + cancel(reason?: unknown) { + return reader.cancel(reason); + }, + releaseLock() { + reader.releaseLock(); + }, + }; +} diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts index 92ef4f5..dee1df8 100644 --- a/src/server/transformers/gemini/generate-content/index.ts +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -1,9 +1,4 @@ -import { - canonicalRequestFromOpenAiBody, - isCanonicalFunctionTool, - isCanonicalNamedToolChoice, -} from '../../canonical/request.js'; -import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { resolveGeminiGenerateContentUrl, @@ -20,12 +15,8 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string { - return asTrimmedString(params?.geminiApiVersion) || 'v1beta'; + return (typeof params?.geminiApiVersion === 'string' ? params.geminiApiVersion.trim() : '') || 'v1beta'; } export function parseGeminiProxyRequestPath(input: { @@ -61,203 +52,21 @@ export function parseGeminiProxyRequestPath(input: { } import { geminiGenerateContentInbound } from './inbound.js'; -import { geminiGenerateContentOutbound } from './outbound.js'; -import { geminiGenerateContentStream } from './stream.js'; +import { geminiGenerateContentResponseBridge } from './responseBridge.js'; +import { geminiGenerateContentStream } from './streamBridge.js'; import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js'; import { geminiGenerateContentUsage } from './usage.js'; import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js'; import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js'; - -function parseJsonString(raw: string): unknown { - const trimmed = raw.trim(); - if (!trimmed) return {}; - try { - return JSON.parse(trimmed); - } catch { - return { raw }; - } -} - -function parseDataUrl(value: string): { mimeType: string; data: string } | null { - const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim()); - if (!match) return null; - return { - mimeType: match[1], - data: match[2], - }; -} - -function canonicalPartToGeminiPart(part: CanonicalContentPart): Record | null { - if (part.type === 'text') { - return { - text: part.text, - ...(part.thought === true ? { thought: true } : {}), - }; - } - - if (part.type === 'image') { - const source = typeof part.dataUrl === 'string' && part.dataUrl.trim() - ? part.dataUrl - : (typeof part.url === 'string' ? part.url : ''); - if (!source) return null; - - const dataUrl = parseDataUrl(source); - if (dataUrl) { - return { - inlineData: { - mimeType: dataUrl.mimeType, - data: dataUrl.data, - }, - }; - } - - return { - fileData: { - fileUri: source, - ...(part.mimeType ? { mimeType: part.mimeType } : {}), - }, - }; - } - - if (part.type === 'file') { - if (part.fileData) { - return { - inlineData: { - mimeType: part.mimeType || 'application/octet-stream', - data: part.fileData, - }, - }; - } - - const fileUri = part.fileUrl || part.fileId; - if (!fileUri) return null; - return { - fileData: { - fileUri, - ...(part.mimeType ? { mimeType: part.mimeType } : {}), - }, - }; - } - - if (part.type === 'tool_call') { - return { - functionCall: { - id: part.id, - name: part.name, - args: parseJsonString(part.argumentsJson), - }, - }; - } - - if (part.type === 'tool_result') { - return { - functionResponse: { - name: part.toolCallId, - response: part.resultJson ?? parseJsonString(part.resultText ?? ''), - }, - }; - } - - return null; -} - -function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Record { - const contents: Array> = []; - const systemParts: Array> = []; - - for (const message of request.messages) { - if (message.role === 'system' || message.role === 'developer') { - systemParts.push( - ...message.parts - .map((part) => canonicalPartToGeminiPart(part)) - .filter((part): part is Record => !!part), - ); - continue; - } - - const parts = message.parts - .map((part) => canonicalPartToGeminiPart(part)) - .filter((part): part is Record => !!part); - - if (parts.length <= 0) continue; - - if (message.role === 'tool') { - contents.push({ - role: 'user', - parts, - }); - continue; - } - - contents.push({ - role: message.role === 'assistant' ? 'model' : 'user', - parts, - }); - } - - const payload: Record = { - contents, - }; - - if (systemParts.length > 0) { - payload.systemInstruction = { - role: 'user', - parts: systemParts, - }; - } - - const generationConfig: Record = {}; - if (request.reasoning?.budgetTokens !== undefined) { - generationConfig.thinkingConfig = { - thinkingBudget: request.reasoning.budgetTokens, - }; - } else if (request.reasoning?.effort) { - generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig( - request.requestedModel, - request.reasoning.effort, - ); - } - if (Object.keys(generationConfig).length > 0) { - payload.generationConfig = generationConfig; - } - - if (Array.isArray(request.tools) && request.tools.length > 0) { - const functionTools = request.tools.filter(isCanonicalFunctionTool); - if (functionTools.length > 0) { - payload.tools = [{ - functionDeclarations: functionTools.map((tool) => ({ - name: tool.name, - ...(tool.description ? { description: tool.description } : {}), - ...(tool.inputSchema ? { parameters: tool.inputSchema } : {}), - })), - }]; - } - } - - if (request.toolChoice) { - if (request.toolChoice === 'none') { - payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } }; - } else if (request.toolChoice === 'auto') { - payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }; - } else if (request.toolChoice === 'required') { - payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } }; - } else if (isCanonicalNamedToolChoice(request.toolChoice)) { - payload.toolConfig = { - functionCallingConfig: { - mode: 'ANY', - allowedFunctionNames: [request.toolChoice.name], - }, - }; - } - } - - return payload; -} +import { + buildCanonicalRequestToGeminiGenerateContentBody, + parseGeminiGenerateContentRequestToCanonical, +} from './requestBridge.js'; export const geminiGenerateContentTransformer = { protocol: 'gemini/generate-content' as const, inbound: geminiGenerateContentInbound, - outbound: geminiGenerateContentOutbound, + outbound: geminiGenerateContentResponseBridge, stream: geminiGenerateContentStream, aggregator: { createState: createGeminiGenerateContentAggregateState, @@ -281,53 +90,19 @@ export const geminiGenerateContentTransformer = { body: unknown, ctx?: ProtocolParseContext, ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { - const rawBody = isRecord(body) ? body : {}; - const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel); - if (!requestedModel) { - return { - error: { - statusCode: 400, - payload: { - error: { - message: 'model is required', - type: 'invalid_request_error', - }, - }, - }, - }; - } - - const stream = rawBody.stream === true || ctx?.metadata?.stream === true; - const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel); - const openAiBody = buildOpenAiBodyFromGeminiRequest({ - body: normalizedBody, - modelName: requestedModel, - stream, - }); - - return { - value: canonicalRequestFromOpenAiBody({ - body: openAiBody, - surface: 'gemini-generate-content', - cliProfile: ctx?.cliProfile, - operation: ctx?.operation, - metadata: ctx?.metadata, - passthrough: ctx?.passthrough, - continuation: ctx?.continuation, - }), - }; + return parseGeminiGenerateContentRequestToCanonical(body, ctx); }, buildProtocolRequest( request: CanonicalRequestEnvelope, _ctx?: ProtocolBuildContext, ): Record { - return buildGeminiRequestFromCanonical(request); + return buildCanonicalRequestToGeminiGenerateContentBody(request); }, }; export { geminiGenerateContentInbound, - geminiGenerateContentOutbound, + geminiGenerateContentResponseBridge as geminiGenerateContentOutbound, geminiGenerateContentStream, createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate, diff --git a/src/server/transformers/gemini/generate-content/outbound.ts b/src/server/transformers/gemini/generate-content/outbound.ts index f692ab8..583b0a4 100644 --- a/src/server/transformers/gemini/generate-content/outbound.ts +++ b/src/server/transformers/gemini/generate-content/outbound.ts @@ -1,279 +1,15 @@ import { - applyGeminiGenerateContentAggregate, - createGeminiGenerateContentAggregateState, - type GeminiGenerateContentAggregateState, -} from './aggregator.js'; -import { - toTransformerMetadataRecord, - type TransformerMetadata, -} from '../../shared/normalized.js'; -import { - resolveGeminiGenerateContentUrl, - resolveGeminiModelsUrl, - resolveGeminiNativeBaseUrl, -} from './urlResolver.js'; + extractGeminiGenerateContentResponseMetadata, + extractGeminiGenerateContentTransformerMetadata, + geminiGenerateContentResponseBridge, + serializeGeminiGenerateContentAggregateResponse, +} from './responseBridge.js'; -type GeminiRecord = Record; +export const geminiGenerateContentOutbound = geminiGenerateContentResponseBridge; -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 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 extractOrderedCandidateMetadata( - state: GeminiGenerateContentAggregateState, - key: 'groundingMetadata' | 'citationMetadata', -): GeminiRecord[] { - if (state.candidates.length > 0) { - return state.candidates - .slice() - .sort((left, right) => left.index - right.index) - .map((candidate) => candidate[key]) - .filter((item): item is GeminiRecord => isRecord(item)) - .map((item) => cloneJsonValue(item)); - } - - const fallback = key === 'groundingMetadata' ? state.groundingMetadata : state.citations; - return fallback.map((item) => cloneJsonValue(item)); -} - -function extractOrderedThoughtSignatures( - state: GeminiGenerateContentAggregateState, -): { - all: string[]; - preferred: string | undefined; -} { - const ordered = new Set(); - const preferredThoughts = new Set(); - - const candidates = state.candidates.length > 0 - ? state.candidates.slice().sort((left, right) => left.index - right.index) - : [{ index: 0, finishReason: state.finishReason, parts: state.parts }]; - - for (const candidate of candidates) { - for (const part of candidate.parts) { - if (!isRecord(part) || typeof part.thoughtSignature !== 'string' || !part.thoughtSignature.trim()) { - continue; - } - ordered.add(part.thoughtSignature); - if (part.thought === true) { - preferredThoughts.add(part.thoughtSignature); - } - } - } - - const orderedList = [...ordered]; - const preferred = [...preferredThoughts][0] - ?? (orderedList.length === 1 ? orderedList[0] : undefined); - - return { - all: orderedList.length > 0 ? orderedList : [...state.thoughtSignatures], - preferred, - }; -} - -function ensurePassthrough(metadata: TransformerMetadata): Record { - if (!metadata.passthrough) metadata.passthrough = {}; - return metadata.passthrough; -} - -function extractRequestSemantics(requestPayload: unknown): TransformerMetadata { - if (!isRecord(requestPayload)) return {}; - - const metadata: TransformerMetadata = {}; - const passthrough = ensurePassthrough(metadata); - if (requestPayload.systemInstruction !== undefined) { - passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction); - } - if (requestPayload.cachedContent !== undefined) { - passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent); - } - if (requestPayload.safetySettings !== undefined) { - metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings); - } - if (requestPayload.toolConfig !== undefined) { - passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig); - } - - const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null; - if (generationConfig) { - const preservedKeys = [ - 'stopSequences', - 'responseModalities', - 'responseMimeType', - 'responseSchema', - 'candidateCount', - 'maxOutputTokens', - 'temperature', - 'topP', - 'topK', - 'presencePenalty', - 'frequencyPenalty', - 'seed', - 'responseLogprobs', - 'logprobs', - 'thinkingConfig', - 'imageConfig', - ]; - for (const key of preservedKeys) { - if (generationConfig[key] !== undefined) { - if (key === 'imageConfig') { - metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]); - } else { - passthrough[key] = cloneJsonValue(generationConfig[key]); - } - } - } - } - - if (Array.isArray(requestPayload.tools)) { - const requestTools = requestPayload.tools - .filter((item) => isRecord(item)) - .map((item) => { - const next: GeminiRecord = {}; - 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); - if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations); - return next; - }) - .filter((item) => Object.keys(item).length > 0); - - if (requestTools.length > 0) { - passthrough.tools = requestTools; - } - } - - if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) { - delete metadata.passthrough; - } - - 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 extractTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata { - const state = ensureAggregateState(payload); - const metadata = extractRequestSemantics(requestPayload); - - const citations = extractOrderedCandidateMetadata(state, 'citationMetadata'); - const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata'); - const thoughtSignatures = extractOrderedThoughtSignatures(state); - - if (citations.length > 0) metadata.citations = citations; - if (groundingMetadata.length > 0) metadata.groundingMetadata = groundingMetadata; - if (thoughtSignatures.all.length > 0) { - if (thoughtSignatures.preferred) { - metadata.thoughtSignature = thoughtSignatures.preferred; - } - metadata.thoughtSignatures = thoughtSignatures.all; - } - const usageMetadata = buildUsageMetadata(state); - if (usageMetadata) metadata.usageMetadata = usageMetadata; - - return metadata; -} - -export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord { - return toTransformerMetadataRecord(extractTransformerMetadata(payload, requestPayload)) ?? {}; -} - -export const geminiGenerateContentOutbound = { - resolveBaseUrl: resolveGeminiNativeBaseUrl, - resolveModelsUrl: resolveGeminiModelsUrl, - resolveActionUrl: resolveGeminiGenerateContentUrl, - extractTransformerMetadata, - extractResponseMetadata, - serializeAggregateResponse: serializeGeminiAggregateResponse, -}; +export { + geminiGenerateContentResponseBridge, + extractGeminiGenerateContentResponseMetadata as extractResponseMetadata, + extractGeminiGenerateContentTransformerMetadata as extractTransformerMetadata, + serializeGeminiGenerateContentAggregateResponse as serializeGeminiAggregateResponse, +} from './responseBridge.js'; diff --git a/src/server/transformers/gemini/generate-content/requestBridge.test.ts b/src/server/transformers/gemini/generate-content/requestBridge.test.ts new file mode 100644 index 0000000..c2ef3f7 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/requestBridge.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildGeminiGenerateContentRequestFromOpenAi, + buildCanonicalRequestToGeminiGenerateContentBody, + parseGeminiGenerateContentRequestToCanonical, +} from './requestBridge.js'; + +describe('gemini generate-content request bridge', () => { + it('parses Gemini generateContent bodies into canonical envelopes', () => { + const result = parseGeminiGenerateContentRequestToCanonical({ + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + generationConfig: { + thinkingConfig: { + thinkingBudget: 512, + }, + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + surface: 'gemini-generate-content', + requestedModel: 'gemini-2.5-pro', + reasoning: { + budgetTokens: 512, + }, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + }); + }); + + it('builds Gemini generateContent bodies from canonical envelopes', () => { + const body = buildCanonicalRequestToGeminiGenerateContentBody({ + operation: 'generate', + surface: 'gemini-generate-content', + cliProfile: 'gemini_cli', + requestedModel: 'gemini-2.5-pro', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + reasoning: { + budgetTokens: 512, + }, + tools: [{ name: 'lookup', inputSchema: { type: 'object' } }], + toolChoice: 'required', + }); + + expect(body).toMatchObject({ + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + tools: [ + { + functionDeclarations: [ + { + name: 'lookup', + parameters: { type: 'object' }, + }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { + mode: 'ANY', + }, + }, + generationConfig: { + thinkingConfig: { + thinkingBudget: 512, + }, + }, + }); + }); + + it('injects provider thought signatures into functionCall parts', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + modelName: 'gemini-3-flash-preview', + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'What is the weather?' }, + { + role: 'assistant', + content: 'Let me check.', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' }, + provider_specific_fields: { thought_signature: 'real_sig_abc' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' }, + ], + }, + }) as Record; + + const contents = result.contents as Array>; + const modelMessages = contents.filter((content) => content.role === 'model'); + const functionCallParts = modelMessages + .flatMap((message) => message.parts as Array>) + .filter((part) => 'functionCall' in part); + + expect(functionCallParts).toHaveLength(1); + expect(functionCallParts[0].thoughtSignature).toBe('real_sig_abc'); + }); + + it('splits text and signed functionCall parts into separate model messages', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + modelName: 'gemini-3-flash-preview', + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'Read the file.' }, + { + role: 'assistant', + content: 'I will read it.', + tool_calls: [ + { + id: 'call_456', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/tmp/x"}' }, + provider_specific_fields: { thought_signature: 'sig_split_test' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_456', content: 'file content here' }, + ], + }, + }) as Record; + + const contents = result.contents as Array>; + const modelMessages = contents.filter((content) => content.role === 'model'); + + expect(modelMessages).toHaveLength(2); + expect((modelMessages[0].parts as Array>).every((part) => 'text' in part)).toBe(true); + expect((modelMessages[1].parts as Array>).every((part) => 'functionCall' in part)).toBe(true); + expect((modelMessages[1].parts as Array>)[0].thoughtSignature).toBe('sig_split_test'); + }); + + it('disables thinking config when signatures are missing for non-gemini targets', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + modelName: 'claude-sonnet-4-5', + body: { + model: 'claude-sonnet-4-5', + reasoning_effort: 'high', + messages: [ + { role: 'user', content: 'Do something.' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_no_sig_non_gemini', + type: 'function', + function: { name: 'Bash', arguments: '{"command":"ls"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_no_sig_non_gemini', content: 'file1\nfile2' }, + ], + }, + }) as Record; + + const contents = result.contents as Array>; + const modelMessages = contents.filter((content) => content.role === 'model'); + const functionCallParts = modelMessages + .flatMap((message) => message.parts as Array>) + .filter((part) => 'functionCall' in part); + + expect(functionCallParts).toHaveLength(1); + expect(functionCallParts[0].thoughtSignature).toBeUndefined(); + expect((result.generationConfig as Record | undefined)?.thinkingConfig).toBeUndefined(); + }); + + it('injects a dummy thought signature when thinking is enabled and no signature is available', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + modelName: 'gemini-3-flash-preview', + body: { + model: 'gemini-3-flash-preview', + reasoning_effort: 'high', + messages: [ + { role: 'user', content: 'Do something.' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_no_sig', + type: 'function', + function: { name: 'Bash', arguments: '{"command":"ls"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' }, + ], + }, + }) as Record; + + const contents = result.contents as Array>; + const modelMessages = contents.filter((content) => content.role === 'model'); + const functionCallParts = modelMessages + .flatMap((message) => message.parts as Array>) + .filter((part) => 'functionCall' in part); + + expect(functionCallParts).toHaveLength(1); + expect(typeof functionCallParts[0].thoughtSignature).toBe('string'); + expect((functionCallParts[0].thoughtSignature as string).length).toBeGreaterThan(0); + }); + + it('preserves functionResponse count matching functionCall count', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + modelName: 'gemini-3-flash-preview', + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'Read two files.' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_a', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/a"}' }, + provider_specific_fields: { thought_signature: 'sig_a' }, + }, + { + id: 'call_b', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/b"}' }, + provider_specific_fields: { thought_signature: 'sig_b' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_a', content: 'content a' }, + { role: 'tool', tool_call_id: 'call_b', content: 'content b' }, + ], + }, + }) as Record; + + const contents = result.contents as Array>; + let functionCallCount = 0; + let functionResponseCount = 0; + for (const content of contents) { + for (const part of (content.parts as Array>)) { + if ('functionCall' in part) functionCallCount += 1; + if ('functionResponse' in part) functionResponseCount += 1; + } + } + + expect(functionCallCount).toBe(2); + expect(functionResponseCount).toBe(2); + }); +}); diff --git a/src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts b/src/server/transformers/gemini/generate-content/requestBridge.thoughtSignature.test.ts similarity index 83% rename from src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts rename to src/server/transformers/gemini/generate-content/requestBridge.thoughtSignature.test.ts index 5fe466a..1128792 100644 --- a/src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts +++ b/src/server/transformers/gemini/generate-content/requestBridge.thoughtSignature.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildGeminiGenerateContentRequestFromOpenAi } from './geminiCliCompat.js'; +import { buildGeminiGenerateContentRequestFromOpenAi } from './requestBridge.js'; describe('thoughtSignature injection in OpenAI→Gemini conversion', () => { it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => { @@ -148,6 +148,40 @@ describe('thoughtSignature injection in OpenAI→Gemini conversion', () => { expect(fcParts[0].thoughtSignature).toBeUndefined(); }); + it('does not inject dummy signature or preserve thinkingConfig for non-gemini targets when signature is missing', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'claude-sonnet-4-5', + reasoning_effort: 'high', + messages: [ + { role: 'user', content: 'Do something.' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_no_sig_non_gemini', + type: 'function', + function: { name: 'Bash', arguments: '{"command":"ls"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_no_sig_non_gemini', content: 'file1\nfile2' }, + ], + }, + modelName: 'claude-sonnet-4-5', + }) as Record; + + const contents = result.contents as Array>; + const modelMsgs = contents.filter((c) => c.role === 'model'); + const fcParts = modelMsgs + .flatMap((m) => (m.parts as Array>)) + .filter((p) => 'functionCall' in p); + + expect(fcParts.length).toBe(1); + expect(fcParts[0].thoughtSignature).toBeUndefined(); + expect((result.generationConfig as Record | undefined)?.thinkingConfig).toBeUndefined(); + }); + it('preserves functionResponse count matching functionCall count', () => { const result = buildGeminiGenerateContentRequestFromOpenAi({ body: { diff --git a/src/server/routes/proxy/geminiCliCompat.ts b/src/server/transformers/gemini/generate-content/requestBridge.ts similarity index 53% rename from src/server/routes/proxy/geminiCliCompat.ts rename to src/server/transformers/gemini/generate-content/requestBridge.ts index 090d60c..4839096 100644 --- a/src/server/routes/proxy/geminiCliCompat.ts +++ b/src/server/transformers/gemini/generate-content/requestBridge.ts @@ -1,19 +1,51 @@ -import { TextDecoder, TextEncoder } from 'node:util'; -import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js'; - -// Dummy sentinel used when no real thoughtSignature is available but thinking -// mode is enabled. Gemini accepts any base64 string and won't reject this. -const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I='; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); -} +import { canonicalRequestFromOpenAiBody } from '../../canonical/openAiRequestBridge.js'; +import { isCanonicalFunctionTool, isCanonicalNamedToolChoice } from '../../canonical/tools.js'; +import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolParseContext } from '../../contracts.js'; +import { geminiGenerateContentInbound } from './inbound.js'; +import { buildOpenAiBodyFromGeminiRequest } from './compatibility.js'; +import { + reasoningEffortToGeminiThinkingConfig, + resolveGeminiThinkingConfigFromRequest, +} from './convert.js'; function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function parseDataUrl(url: string): { mimeType: string; data: string } | null { +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseJsonString(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { raw }; + } +} + +function parseDataUrl(value: string): { mimeType: string; data: string } | null { + const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim()); + if (!match) return null; + return { + mimeType: match[1], + data: match[2], + }; +} + +// Dummy sentinel used when no real thoughtSignature is available but thinking +// mode is enabled. Gemini accepts any base64 string and won't reject this. +const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I='; + +function isDummyThoughtSafeModel(modelName: string): boolean { + const normalized = asTrimmedString(modelName).toLowerCase(); + return normalized.startsWith('gemini-') || normalized.startsWith('models/gemini-'); +} + +function parseInlineDataUrl(url: string): { mimeType: string; data: string } | null { if (!url.startsWith('data:')) return null; const [, rest] = url.split('data:', 2); const [meta, data] = rest.split(',', 2); @@ -36,7 +68,7 @@ function normalizeFunctionResponseResult(value: unknown): unknown { } } -function convertContentToGeminiParts(content: unknown): Array> { +function convertOpenAiContentToGeminiParts(content: unknown): Array> { if (typeof content === 'string') { const trimmed = content.trim(); return trimmed ? [{ text: trimmed }] : []; @@ -63,7 +95,7 @@ function convertContentToGeminiParts(content: unknown): Array> | undefined { +function buildGeminiToolsFromOpenAi(tools: unknown): Array> | undefined { if (!Array.isArray(tools)) return undefined; const declarations = tools .filter((item) => isRecord(item)) @@ -109,7 +141,7 @@ function buildGeminiTools(tools: unknown): Array> | unde return [{ functionDeclarations: declarations }]; } -function buildGeminiToolConfig(toolChoice: unknown): Record | undefined { +function buildGeminiToolConfigFromOpenAi(toolChoice: unknown): Record | undefined { if (typeof toolChoice === 'string') { const normalized = toolChoice.trim().toLowerCase(); if (normalized === 'none') { @@ -147,11 +179,10 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { } } - // Detect if thinking mode is enabled (needed for dummy signature fallback) const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); + const allowsDummyThoughtSignature = isDummyThoughtSafeModel(input.modelName); + let shouldDisableThinkingConfig = false; - // Collect thoughtSignatures from assistant tool_calls for injection. - // OpenAI format stores signatures in provider_specific_fields or encoded in tool call IDs. const thoughtSignatureById = new Map(); for (const message of messages) { if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue; @@ -160,7 +191,6 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { if (!isRecord(toolCall)) continue; const id = asTrimmedString(toolCall.id); if (!id) continue; - // Check provider_specific_fields first const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null; if (providerFields && typeof providerFields.thought_signature === 'string') { thoughtSignatureById.set(id, providerFields.thought_signature); @@ -177,7 +207,7 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { if (!isRecord(message)) continue; const role = asTrimmedString(message.role).toLowerCase(); if (role === 'system' || role === 'developer') { - systemParts.push(...convertContentToGeminiParts(message.content)); + systemParts.push(...convertOpenAiContentToGeminiParts(message.content)); continue; } if (role === 'tool') { @@ -201,7 +231,7 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { continue; } - const textParts = convertContentToGeminiParts(message.content); + const textParts = convertOpenAiContentToGeminiParts(message.content); const fcParts: Array> = []; const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; for (const toolCall of toolCalls) { @@ -222,29 +252,21 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { const fcPart: Record = { functionCall: { name, args }, }; - // Inject thoughtSignature if available from provider_specific_fields. - // If no signature is found, use a dummy sentinel so Gemini doesn't reject - // the request outright. Gemini 3+ requires thoughtSignature on all - // functionCall parts when thinking is enabled. - // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures const id = asTrimmedString(toolCall.id); const signature = thoughtSignatureById.get(id); if (signature) { fcPart.thoughtSignature = signature; - } else if (hasThinkingEnabled) { + } else if (hasThinkingEnabled && allowsDummyThoughtSignature) { fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE; + } else if (hasThinkingEnabled) { + shouldDisableThinkingConfig = true; } fcParts.push(fcPart); } const geminiRole = role === 'assistant' ? 'model' : 'user'; - - // Gemini requires that parts WITH thoughtSignature and parts WITHOUT - // are not mixed in the same message. Split if needed. - // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures - const hasSigned = fcParts.some((p) => 'thoughtSignature' in p); + const hasSigned = fcParts.some((part) => 'thoughtSignature' in part); if (hasSigned && textParts.length > 0 && fcParts.length > 0) { - // Emit text parts first, then functionCall parts in a separate message request.contents = [ ...(Array.isArray(request.contents) ? request.contents : []), { role: geminiRole, parts: textParts }, @@ -287,18 +309,18 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); } const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); - if (thinkingConfig) { + if (thinkingConfig && !shouldDisableThinkingConfig) { generationConfig.thinkingConfig = thinkingConfig; } if (Object.keys(generationConfig).length > 0) { request.generationConfig = generationConfig; } - const geminiTools = buildGeminiTools(input.body.tools); + const geminiTools = buildGeminiToolsFromOpenAi(input.body.tools); if (geminiTools) { request.tools = geminiTools; } - const toolConfig = buildGeminiToolConfig(input.body.tool_choice); + const toolConfig = buildGeminiToolConfigFromOpenAi(input.body.tool_choice); if (toolConfig) { request.toolConfig = toolConfig; } @@ -306,91 +328,212 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { return request; } -export function wrapGeminiCliRequest(input: { - modelName: string; - projectId: string; - request: Record; -}) { - const { model, ...requestPayload } = input.request; - return { - project: input.projectId, - model: input.modelName, - request: requestPayload, - }; +function canonicalPartToGeminiPart(part: CanonicalContentPart): Record | null { + if (part.type === 'text') { + return { + text: part.text, + ...(part.thought === true ? { thought: true } : {}), + }; + } + + if (part.type === 'image') { + const source = typeof part.dataUrl === 'string' && part.dataUrl.trim() + ? part.dataUrl + : (typeof part.url === 'string' ? part.url : ''); + if (!source) return null; + + const dataUrl = parseDataUrl(source); + if (dataUrl) { + return { + inlineData: { + mimeType: dataUrl.mimeType, + data: dataUrl.data, + }, + }; + } + + return { + fileData: { + fileUri: source, + ...(part.mimeType ? { mimeType: part.mimeType } : {}), + }, + }; + } + + if (part.type === 'file') { + if (part.fileData) { + return { + inlineData: { + mimeType: part.mimeType || 'application/octet-stream', + data: part.fileData, + }, + }; + } + + const fileUri = part.fileUrl || part.fileId; + if (!fileUri) return null; + return { + fileData: { + fileUri, + ...(part.mimeType ? { mimeType: part.mimeType } : {}), + }, + }; + } + + if (part.type === 'tool_call') { + return { + functionCall: { + id: part.id, + name: part.name, + args: parseJsonString(part.argumentsJson), + }, + }; + } + + if (part.type === 'tool_result') { + return { + functionResponse: { + name: part.toolCallId, + response: part.resultJson ?? parseJsonString(part.resultText ?? ''), + }, + }; + } + + return null; } -export function unwrapGeminiCliPayload(payload: T): unknown { - if (!isRecord(payload)) return payload; - if (payload.response !== undefined) { - return payload.response; +export function buildCanonicalRequestToGeminiGenerateContentBody( + request: CanonicalRequestEnvelope, +): Record { + const contents: Array> = []; + const systemParts: Array> = []; + + for (const message of request.messages) { + if (message.role === 'system' || message.role === 'developer') { + systemParts.push( + ...message.parts + .map((part) => canonicalPartToGeminiPart(part)) + .filter((part): part is Record => !!part), + ); + continue; + } + + const parts = message.parts + .map((part) => canonicalPartToGeminiPart(part)) + .filter((part): part is Record => !!part); + + if (parts.length <= 0) continue; + + if (message.role === 'tool') { + contents.push({ + role: 'user', + parts, + }); + continue; + } + + contents.push({ + role: message.role === 'assistant' ? 'model' : 'user', + parts, + }); } + + const payload: Record = { + contents, + }; + + if (systemParts.length > 0) { + payload.systemInstruction = { + role: 'user', + parts: systemParts, + }; + } + + const generationConfig: Record = {}; + if (request.reasoning?.budgetTokens !== undefined) { + generationConfig.thinkingConfig = { + thinkingBudget: request.reasoning.budgetTokens, + }; + } else if (request.reasoning?.effort) { + generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig( + request.requestedModel, + request.reasoning.effort, + ); + } + if (Object.keys(generationConfig).length > 0) { + payload.generationConfig = generationConfig; + } + + if (Array.isArray(request.tools) && request.tools.length > 0) { + const functionTools = request.tools.filter(isCanonicalFunctionTool); + if (functionTools.length > 0) { + payload.tools = [{ + functionDeclarations: functionTools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(tool.inputSchema ? { parameters: tool.inputSchema } : {}), + })), + }]; + } + } + + if (request.toolChoice) { + if (request.toolChoice === 'none') { + payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } }; + } else if (request.toolChoice === 'auto') { + payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }; + } else if (request.toolChoice === 'required') { + payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } }; + } else if (isCanonicalNamedToolChoice(request.toolChoice)) { + payload.toolConfig = { + functionCallingConfig: { + mode: 'ANY', + allowedFunctionNames: [request.toolChoice.name], + }, + }; + } + } + return payload; } -function rewriteGeminiCliSseEventBlock(block: string): string { - const lines = block.split(/\r?\n/g); - return lines.map((line) => { - if (!line.startsWith('data:')) return line; - const data = line.slice(5).trim(); - if (!data || data === '[DONE]') return line; - try { - const parsed = JSON.parse(data); - return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`; - } catch { - return line; - } - }).join('\n'); -} - -export function createGeminiCliStreamReader(reader: { - read(): Promise<{ done: boolean; value?: Uint8Array }>; - cancel(reason?: unknown): Promise; - releaseLock(): void; -}) { - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - const outputQueue: Uint8Array[] = []; - let buffer = ''; - let done = false; - - async function fillQueue() { - while (outputQueue.length <= 0 && !done) { - const result = await reader.read(); - if (result.done) { - done = true; - const tail = decoder.decode(); - if (tail) buffer += tail; - if (buffer.trim()) { - outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`)); - buffer = ''; - } - break; - } - if (!result.value) continue; - buffer += decoder.decode(result.value, { stream: true }); - let separatorIndex = buffer.indexOf('\n\n'); - while (separatorIndex >= 0) { - const block = buffer.slice(0, separatorIndex); - buffer = buffer.slice(separatorIndex + 2); - outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`)); - separatorIndex = buffer.indexOf('\n\n'); - } - } +export function parseGeminiGenerateContentRequestToCanonical( + body: unknown, + ctx?: ProtocolParseContext, +): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const rawBody = isRecord(body) ? body : {}; + const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel); + if (!requestedModel) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'model is required', + type: 'invalid_request_error', + }, + }, + }, + }; } + const stream = rawBody.stream === true || ctx?.metadata?.stream === true; + const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel); + const openAiBody = buildOpenAiBodyFromGeminiRequest({ + body: normalizedBody, + modelName: requestedModel, + stream, + }); + return { - async read() { - await fillQueue(); - if (outputQueue.length > 0) { - return { done: false, value: outputQueue.shift() }; - } - return { done: true, value: undefined }; - }, - cancel(reason?: unknown) { - return reader.cancel(reason); - }, - releaseLock() { - reader.releaseLock(); - }, + value: canonicalRequestFromOpenAiBody({ + body: openAiBody, + surface: 'gemini-generate-content', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), }; } diff --git a/src/server/transformers/gemini/generate-content/responseBridge.test.ts b/src/server/transformers/gemini/generate-content/responseBridge.test.ts new file mode 100644 index 0000000..1454ab3 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/responseBridge.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractGeminiGenerateContentResponseMetadata, + extractGeminiGenerateContentTransformerMetadata, + geminiGenerateContentResponseBridge, + serializeGeminiGenerateContentAggregateResponse, +} from './responseBridge.js'; +import { geminiGenerateContentOutbound } from './outbound.js'; + +describe('gemini generate-content response bridge', () => { + it('serializes aggregate responses with usage metadata', () => { + const payload = serializeGeminiGenerateContentAggregateResponse({ + responseId: 'resp-1', + modelVersion: 'gemini-2.5-pro', + finishReason: 'STOP', + parts: [{ text: 'hello' }], + candidates: [], + groundingMetadata: [], + citations: [], + thoughtSignatures: [], + usage: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + }, + }); + + expect(payload).toEqual({ + responseId: 'resp-1', + modelVersion: 'gemini-2.5-pro', + candidates: [{ + index: 0, + finishReason: 'STOP', + content: { + role: 'model', + parts: [{ text: 'hello' }], + }, + }], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + }, + }); + }); + + it('extracts transformer and response metadata from aggregate payloads', () => { + const payload = { + responseId: 'resp-1', + modelVersion: 'gemini-2.5-pro', + finishReason: 'STOP', + parts: [{ text: 'hello', thoughtSignature: 'sig-1', thought: true }], + candidates: [], + groundingMetadata: [{ webSearchQueries: ['cat'] }], + citations: [{ citations: [{ uri: 'https://a.example' }] }], + thoughtSignatures: ['sig-1'], + usage: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + }, + }; + + expect(extractGeminiGenerateContentTransformerMetadata(payload, { + systemInstruction: { parts: [{ text: 'system prompt' }] }, + })).toMatchObject({ + citations: [{ citations: [{ uri: 'https://a.example' }] }], + groundingMetadata: [{ webSearchQueries: ['cat'] }], + thoughtSignature: 'sig-1', + thoughtSignatures: ['sig-1'], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + }, + passthrough: { + systemInstruction: { parts: [{ text: 'system prompt' }] }, + }, + }); + + expect(extractGeminiGenerateContentResponseMetadata(payload)).toMatchObject({ + citations: [{ citations: [{ uri: 'https://a.example' }] }], + groundingMetadata: [{ webSearchQueries: ['cat'] }], + thoughtSignature: 'sig-1', + thoughtSignatures: ['sig-1'], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + }, + }); + }); + + it('keeps the outbound facade pointed at the response bridge object', () => { + expect(geminiGenerateContentOutbound).toBe(geminiGenerateContentResponseBridge); + }); +}); diff --git a/src/server/transformers/gemini/generate-content/responseBridge.ts b/src/server/transformers/gemini/generate-content/responseBridge.ts new file mode 100644 index 0000000..34c5668 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/responseBridge.ts @@ -0,0 +1,287 @@ +import { + applyGeminiGenerateContentAggregate, + createGeminiGenerateContentAggregateState, + type GeminiGenerateContentAggregateState, +} from './aggregator.js'; +import { + toTransformerMetadataRecord, + type TransformerMetadata, +} from '../../shared/normalized.js'; +import { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; + +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 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 extractOrderedCandidateMetadata( + state: GeminiGenerateContentAggregateState, + key: 'groundingMetadata' | 'citationMetadata', +): GeminiRecord[] { + if (state.candidates.length > 0) { + return state.candidates + .slice() + .sort((left, right) => left.index - right.index) + .map((candidate) => candidate[key]) + .filter((item): item is GeminiRecord => isRecord(item)) + .map((item) => cloneJsonValue(item)); + } + + const fallback = key === 'groundingMetadata' ? state.groundingMetadata : state.citations; + return fallback.map((item) => cloneJsonValue(item)); +} + +function extractOrderedThoughtSignatures( + state: GeminiGenerateContentAggregateState, +): { + all: string[]; + preferred: string | undefined; +} { + const ordered = new Set(); + const preferredThoughts = new Set(); + + const candidates = state.candidates.length > 0 + ? state.candidates.slice().sort((left, right) => left.index - right.index) + : [{ index: 0, finishReason: state.finishReason, parts: state.parts }]; + + for (const candidate of candidates) { + for (const part of candidate.parts) { + if (!isRecord(part) || typeof part.thoughtSignature !== 'string' || !part.thoughtSignature.trim()) { + continue; + } + ordered.add(part.thoughtSignature); + if (part.thought === true) { + preferredThoughts.add(part.thoughtSignature); + } + } + } + + const orderedList = [...ordered]; + const preferred = [...preferredThoughts][0] + ?? (orderedList.length === 1 ? orderedList[0] : undefined); + + return { + all: orderedList.length > 0 ? orderedList : [...state.thoughtSignatures], + preferred, + }; +} + +function ensurePassthrough(metadata: TransformerMetadata): Record { + if (!metadata.passthrough) metadata.passthrough = {}; + return metadata.passthrough; +} + +function extractRequestSemantics(requestPayload: unknown): TransformerMetadata { + if (!isRecord(requestPayload)) return {}; + + const metadata: TransformerMetadata = {}; + const passthrough = ensurePassthrough(metadata); + if (requestPayload.systemInstruction !== undefined) { + passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction); + } + if (requestPayload.cachedContent !== undefined) { + passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent); + } + if (requestPayload.safetySettings !== undefined) { + metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings); + } + if (requestPayload.toolConfig !== undefined) { + passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig); + } + + const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null; + if (generationConfig) { + const preservedKeys = [ + 'stopSequences', + 'responseModalities', + 'responseMimeType', + 'responseSchema', + 'candidateCount', + 'maxOutputTokens', + 'temperature', + 'topP', + 'topK', + 'presencePenalty', + 'frequencyPenalty', + 'seed', + 'responseLogprobs', + 'logprobs', + 'thinkingConfig', + 'imageConfig', + ]; + for (const key of preservedKeys) { + if (generationConfig[key] !== undefined) { + if (key === 'imageConfig') { + metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]); + } else { + passthrough[key] = cloneJsonValue(generationConfig[key]); + } + } + } + } + + if (Array.isArray(requestPayload.tools)) { + const requestTools = requestPayload.tools + .filter((item) => isRecord(item)) + .map((item) => { + const next: GeminiRecord = {}; + 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); + if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations); + return next; + }) + .filter((item) => Object.keys(item).length > 0); + + if (requestTools.length > 0) { + passthrough.tools = requestTools; + } + } + + if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) { + delete metadata.passthrough; + } + + return metadata; +} + +export function serializeGeminiGenerateContentAggregateResponse( + 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 extractGeminiGenerateContentTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata { + const state = ensureAggregateState(payload); + const metadata = extractRequestSemantics(requestPayload); + + const citations = extractOrderedCandidateMetadata(state, 'citationMetadata'); + const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata'); + const thoughtSignatures = extractOrderedThoughtSignatures(state); + + if (citations.length > 0) metadata.citations = citations; + if (groundingMetadata.length > 0) metadata.groundingMetadata = groundingMetadata; + if (thoughtSignatures.all.length > 0) { + if (thoughtSignatures.preferred) { + metadata.thoughtSignature = thoughtSignatures.preferred; + } + metadata.thoughtSignatures = thoughtSignatures.all; + } + const usageMetadata = buildUsageMetadata(state); + if (usageMetadata) metadata.usageMetadata = usageMetadata; + + return metadata; +} + +export function extractGeminiGenerateContentResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord { + return toTransformerMetadataRecord(extractGeminiGenerateContentTransformerMetadata(payload, requestPayload)) ?? {}; +} + +export const geminiGenerateContentResponseBridge = { + resolveBaseUrl: resolveGeminiNativeBaseUrl, + resolveModelsUrl: resolveGeminiModelsUrl, + resolveActionUrl: resolveGeminiGenerateContentUrl, + extractTransformerMetadata: extractGeminiGenerateContentTransformerMetadata, + extractResponseMetadata: extractGeminiGenerateContentResponseMetadata, + serializeAggregateResponse: serializeGeminiGenerateContentAggregateResponse, +}; + +export const geminiGenerateContentOutbound = geminiGenerateContentResponseBridge; + +export { + extractGeminiGenerateContentResponseMetadata as extractResponseMetadata, + extractGeminiGenerateContentTransformerMetadata as extractTransformerMetadata, + serializeGeminiGenerateContentAggregateResponse as serializeGeminiAggregateResponse, +}; diff --git a/src/server/transformers/gemini/generate-content/stream.ts b/src/server/transformers/gemini/generate-content/stream.ts index 010750f..c00d9a8 100644 --- a/src/server/transformers/gemini/generate-content/stream.ts +++ b/src/server/transformers/gemini/generate-content/stream.ts @@ -1,253 +1,21 @@ -import { - applyGeminiGenerateContentAggregate, - createGeminiGenerateContentAggregateState, - type GeminiGenerateContentAggregateState, -} from './aggregator.js'; -import { serializeGeminiAggregateResponse } from './outbound.js'; - -type ParsedSsePayloads = { - events: unknown[]; - lines: string[]; - rest: string; -}; - -type GeminiGenerateContentStreamFormat = 'sse' | 'json'; - -type ParsedGeminiStreamPayload = { - format: GeminiGenerateContentStreamFormat; - events: unknown[]; - lines?: string[]; - rest: string; -}; - -type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & { - state: GeminiGenerateContentAggregateState; -}; - -function serializeSsePayload(payload: unknown): string { - return `data: ${JSON.stringify(payload)}\n\n`; -} - -function parseSsePayloads(buffer: string): ParsedSsePayloads { - const events: unknown[] = []; - const lines: string[] = []; - let cursor = 0; - - while (cursor < buffer.length) { - const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor)); - if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break; - - const boundary = cursor + boundaryMatch.index; - const block = buffer.slice(cursor, boundary); - const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length); - cursor = boundary + boundaryMatch[0].length; - - if (!block.trim()) continue; - lines.push(rawBlock); - - const data = block - .replace(/\r\n/g, '\n') - .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, - lines, - rest: buffer.slice(cursor), - }; -} - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - -function parseJsonArrayPayload(payload: unknown): unknown[] { - if (Array.isArray(payload)) return payload; - if (isRecord(payload)) return [payload]; - return []; -} - -function parseGeminiStreamPayload( - payload: unknown, - contentType?: string | null, -): ParsedGeminiStreamPayload { - if (geminiGenerateContentStream.isSseContentType(contentType)) { - const parsed = parseSsePayloads(String(payload ?? '')); - return { - format: 'sse', - events: parsed.events, - lines: parsed.lines, - rest: parsed.rest, - }; - } - - if (typeof payload === 'string') { - const trimmed = payload.trim(); - if (trimmed) { - try { - const parsedJson = JSON.parse(trimmed); - return { - format: 'json', - events: parseJsonArrayPayload(parsedJson), - rest: '', - }; - } catch { - const parsed = parseSsePayloads(payload); - return { - format: 'sse', - events: parsed.events, - rest: parsed.rest, - }; - } - } - return { - format: 'json', - events: [], - rest: '', - }; - } - - return { - format: 'json', - events: parseJsonArrayPayload(payload), - rest: '', - }; -} - -function applyParsedPayloadToAggregate( - state: GeminiGenerateContentAggregateState, - parsed: ParsedGeminiStreamPayload, -): AppliedGeminiStreamPayloads { - for (const event of parsed.events) { - applyGeminiGenerateContentAggregate(state, event); - } - - return { - ...parsed, - state, - }; -} - -function applyJsonPayloadToAggregate( - state: GeminiGenerateContentAggregateState, - payload: unknown, -): GeminiGenerateContentAggregateState { - applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(payload, 'application/json')); - return state; -} - -function applySsePayloadsToAggregate( - state: GeminiGenerateContentAggregateState, - buffer: string, -): AppliedGeminiStreamPayloads { - return applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(buffer, 'text/event-stream')); -} - -function consumeUpstreamSseBuffer( - state: GeminiGenerateContentAggregateState, - buffer: string, -): AppliedGeminiStreamPayloads & { lines: string[] } { - const applied = applySsePayloadsToAggregate(state, buffer); - return { - ...applied, - lines: applied.lines ?? [], - }; -} - -function serializeAggregateJsonPayload( - payload: GeminiGenerateContentAggregateState | unknown, -): unknown { - return serializeGeminiAggregateResponse(payload); -} - -function serializeAggregateSsePayload( - payload: GeminiGenerateContentAggregateState | unknown, -): string { - return serializeSsePayload(serializeAggregateJsonPayload(payload)); -} - -function serializeAggregatePayload( - payload: GeminiGenerateContentAggregateState | unknown, - format: GeminiGenerateContentStreamFormat = 'json', -): unknown { - return format === 'sse' - ? serializeAggregateSsePayload(payload) - : serializeAggregateJsonPayload(payload); -} - -function serializeUpstreamJsonPayload( - state: GeminiGenerateContentAggregateState, - payload: unknown, - streamAction = false, -): unknown { - if (streamAction) { - const events = parseJsonArrayPayload(payload); - for (const event of events) { - applyGeminiGenerateContentAggregate(state, event); - } - return payload; - } - - applyJsonPayloadToAggregate(state, payload); - return serializeAggregateJsonPayload(state); -} - -export const geminiGenerateContentStream = { - isSseContentType(contentType: string | null | undefined): boolean { - return (contentType || '').toLowerCase().includes('text/event-stream'); - }, - - parseJsonArrayPayload, - parseGeminiStreamPayload, - parseSsePayloads, - serializeSsePayload, - serializeAggregateJsonPayload, - serializeAggregatePayload, - serializeAggregateSsePayload, - serializeUpstreamJsonPayload, - applyParsedPayloadToAggregate, - applyJsonPayloadToAggregate, - applySsePayloadsToAggregate, - consumeUpstreamSseBuffer, - - createAggregateState(): GeminiGenerateContentAggregateState { - return createGeminiGenerateContentAggregateState(); - }, - - applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState { - return applyGeminiGenerateContentAggregate(state, payload); - }, -}; - export { - applyParsedPayloadToAggregate, applyJsonPayloadToAggregate, + applyParsedPayloadToAggregate, applySsePayloadsToAggregate, + consumeUpstreamSseBuffer, + geminiGenerateContentStream, parseGeminiStreamPayload, parseJsonArrayPayload, parseSsePayloads, serializeAggregateJsonPayload, serializeAggregatePayload, serializeAggregateSsePayload, - serializeUpstreamJsonPayload, serializeSsePayload, - consumeUpstreamSseBuffer, -}; + serializeUpstreamJsonPayload, +} from './streamBridge.js'; export type { AppliedGeminiStreamPayloads, GeminiGenerateContentStreamFormat, ParsedGeminiStreamPayload, ParsedSsePayloads, -}; +} from './streamBridge.js'; diff --git a/src/server/transformers/gemini/generate-content/streamBridge.test.ts b/src/server/transformers/gemini/generate-content/streamBridge.test.ts new file mode 100644 index 0000000..7297942 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/streamBridge.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + consumeUpstreamSseBuffer, + geminiGenerateContentStream, + parseGeminiStreamPayload, + serializeAggregateSsePayload, +} from './streamBridge.js'; + +describe('gemini generate-content stream bridge', () => { + it('parses SSE payloads and preserves trailing rest blocks', () => { + const parsed = parseGeminiStreamPayload([ + 'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]}}]}', + '', + 'data: [DONE]', + '', + 'data: {"responseId":"partial"', + ].join('\n'), 'text/event-stream'); + + expect(parsed.format).toBe('sse'); + expect(parsed.events).toHaveLength(1); + expect(parsed.rest).toBe('data: {"responseId":"partial"'); + }); + + it('aggregates upstream SSE blocks while preserving raw lines and serializable final output', () => { + const state = geminiGenerateContentStream.createAggregateState(); + const result = consumeUpstreamSseBuffer(state, [ + 'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}]}', + '', + 'data: [DONE]', + '', + '', + ].join('\n')); + + expect(result.lines).toHaveLength(2); + expect(result.events).toHaveLength(1); + expect(result.rest).toBe(''); + expect(serializeAggregateSsePayload(result.state)).toContain('"responseId":"resp-1"'); + }); +}); diff --git a/src/server/transformers/gemini/generate-content/streamBridge.ts b/src/server/transformers/gemini/generate-content/streamBridge.ts new file mode 100644 index 0000000..81a45a0 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/streamBridge.ts @@ -0,0 +1,253 @@ +import { + applyGeminiGenerateContentAggregate, + createGeminiGenerateContentAggregateState, + type GeminiGenerateContentAggregateState, +} from './aggregator.js'; +import { serializeGeminiGenerateContentAggregateResponse } from './responseBridge.js'; + +type ParsedSsePayloads = { + events: unknown[]; + lines: string[]; + rest: string; +}; + +type GeminiGenerateContentStreamFormat = 'sse' | 'json'; + +type ParsedGeminiStreamPayload = { + format: GeminiGenerateContentStreamFormat; + events: unknown[]; + lines?: string[]; + rest: string; +}; + +type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & { + state: GeminiGenerateContentAggregateState; +}; + +function serializeSsePayload(payload: unknown): string { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +function parseSsePayloads(buffer: string): ParsedSsePayloads { + const events: unknown[] = []; + const lines: string[] = []; + let cursor = 0; + + while (cursor < buffer.length) { + const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor)); + if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break; + + const boundary = cursor + boundaryMatch.index; + const block = buffer.slice(cursor, boundary); + const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length); + cursor = boundary + boundaryMatch[0].length; + + if (!block.trim()) continue; + lines.push(rawBlock); + + const data = block + .replace(/\r\n/g, '\n') + .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, + lines, + rest: buffer.slice(cursor), + }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseJsonArrayPayload(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + if (isRecord(payload)) return [payload]; + return []; +} + +function parseGeminiStreamPayload( + payload: unknown, + contentType?: string | null, +): ParsedGeminiStreamPayload { + if (geminiGenerateContentStream.isSseContentType(contentType)) { + const parsed = parseSsePayloads(String(payload ?? '')); + return { + format: 'sse', + events: parsed.events, + lines: parsed.lines, + rest: parsed.rest, + }; + } + + if (typeof payload === 'string') { + const trimmed = payload.trim(); + if (trimmed) { + try { + const parsedJson = JSON.parse(trimmed); + return { + format: 'json', + events: parseJsonArrayPayload(parsedJson), + rest: '', + }; + } catch { + const parsed = parseSsePayloads(payload); + return { + format: 'sse', + events: parsed.events, + rest: parsed.rest, + }; + } + } + return { + format: 'json', + events: [], + rest: '', + }; + } + + return { + format: 'json', + events: parseJsonArrayPayload(payload), + rest: '', + }; +} + +function applyParsedPayloadToAggregate( + state: GeminiGenerateContentAggregateState, + parsed: ParsedGeminiStreamPayload, +): AppliedGeminiStreamPayloads { + for (const event of parsed.events) { + applyGeminiGenerateContentAggregate(state, event); + } + + return { + ...parsed, + state, + }; +} + +function applyJsonPayloadToAggregate( + state: GeminiGenerateContentAggregateState, + payload: unknown, +): GeminiGenerateContentAggregateState { + applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(payload, 'application/json')); + return state; +} + +function applySsePayloadsToAggregate( + state: GeminiGenerateContentAggregateState, + buffer: string, +): AppliedGeminiStreamPayloads { + return applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(buffer, 'text/event-stream')); +} + +function consumeUpstreamSseBuffer( + state: GeminiGenerateContentAggregateState, + buffer: string, +): AppliedGeminiStreamPayloads & { lines: string[] } { + const applied = applySsePayloadsToAggregate(state, buffer); + return { + ...applied, + lines: applied.lines ?? [], + }; +} + +function serializeAggregateJsonPayload( + payload: GeminiGenerateContentAggregateState | unknown, +): unknown { + return serializeGeminiGenerateContentAggregateResponse(payload); +} + +function serializeAggregateSsePayload( + payload: GeminiGenerateContentAggregateState | unknown, +): string { + return serializeSsePayload(serializeAggregateJsonPayload(payload)); +} + +function serializeAggregatePayload( + payload: GeminiGenerateContentAggregateState | unknown, + format: GeminiGenerateContentStreamFormat = 'json', +): unknown { + return format === 'sse' + ? serializeAggregateSsePayload(payload) + : serializeAggregateJsonPayload(payload); +} + +function serializeUpstreamJsonPayload( + state: GeminiGenerateContentAggregateState, + payload: unknown, + streamAction = false, +): unknown { + if (streamAction) { + const events = parseJsonArrayPayload(payload); + for (const event of events) { + applyGeminiGenerateContentAggregate(state, event); + } + return payload; + } + + applyJsonPayloadToAggregate(state, payload); + return serializeAggregateJsonPayload(state); +} + +export const geminiGenerateContentStream = { + isSseContentType(contentType: string | null | undefined): boolean { + return (contentType || '').toLowerCase().includes('text/event-stream'); + }, + + parseJsonArrayPayload, + parseGeminiStreamPayload, + parseSsePayloads, + serializeSsePayload, + serializeAggregateJsonPayload, + serializeAggregatePayload, + serializeAggregateSsePayload, + serializeUpstreamJsonPayload, + applyParsedPayloadToAggregate, + applyJsonPayloadToAggregate, + applySsePayloadsToAggregate, + consumeUpstreamSseBuffer, + + createAggregateState(): GeminiGenerateContentAggregateState { + return createGeminiGenerateContentAggregateState(); + }, + + applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState { + return applyGeminiGenerateContentAggregate(state, payload); + }, +}; + +export { + applyParsedPayloadToAggregate, + applyJsonPayloadToAggregate, + applySsePayloadsToAggregate, + parseGeminiStreamPayload, + parseJsonArrayPayload, + parseSsePayloads, + serializeAggregateJsonPayload, + serializeAggregatePayload, + serializeAggregateSsePayload, + serializeUpstreamJsonPayload, + serializeSsePayload, + consumeUpstreamSseBuffer, +}; +export type { + AppliedGeminiStreamPayloads, + GeminiGenerateContentStreamFormat, + ParsedGeminiStreamPayload, + ParsedSsePayloads, +}; diff --git a/src/server/transformers/openai/chat/index.test.ts b/src/server/transformers/openai/chat/index.test.ts index 200c7ba..5a957bc 100644 --- a/src/server/transformers/openai/chat/index.test.ts +++ b/src/server/transformers/openai/chat/index.test.ts @@ -71,6 +71,45 @@ describe('openAiChatTransformer.inbound', () => { }); }); + it('round-trips continuation turnState through chat metadata bridging', () => { + const built = openAiChatTransformer.buildProtocolRequest({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + sessionId: 'chat-session-1', + turnState: 'turn-state-chat-1', + }, + }); + + expect(built).toMatchObject({ + metadata: { + user_id: 'chat-session-1', + metapi_turn_state: 'turn-state-chat-1', + }, + }); + + const parsed = openAiChatTransformer.parseRequest({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + metadata: { + user_id: 'chat-session-1', + metapi_turn_state: 'turn-state-chat-1', + }, + }); + + expect(parsed.error).toBeUndefined(); + expect(parsed.value).toMatchObject({ + continuation: { + sessionId: 'chat-session-1', + turnState: 'turn-state-chat-1', + }, + }); + }); + it('captures chat request metadata fields without changing upstream body', () => { const result = openAiChatTransformer.transformRequest({ model: 'gpt-5', @@ -277,6 +316,65 @@ describe('openAiChatTransformer.outbound', () => { expect((normalized as any).citations).toEqual(['https://shared.example/citation']); }); + it('maps final Responses payload terminal statuses to sub2api-like chat finish reasons', () => { + const incompleteStop = openAiChatTransformer.transformFinalResponse({ + id: 'resp_final_stop', + object: 'response', + model: 'gpt-5', + status: 'incomplete', + output: [], + }, 'gpt-5'); + + const incompleteLength = openAiChatTransformer.transformFinalResponse({ + id: 'resp_final_length', + object: 'response', + model: 'gpt-5', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + output: [], + }, 'gpt-5'); + + const failedStop = openAiChatTransformer.transformFinalResponse({ + id: 'resp_final_failed', + object: 'response', + model: 'gpt-5', + status: 'failed', + output: [], + }, 'gpt-5'); + + expect(openAiChatTransformer.serializeFinalResponse(incompleteStop, { + promptTokens: 1, + completionTokens: 1, + totalTokens: 2, + })).toMatchObject({ + choices: [{ + finish_reason: 'stop', + }], + }); + + expect(openAiChatTransformer.serializeFinalResponse(incompleteLength, { + promptTokens: 1, + completionTokens: 1, + totalTokens: 2, + })).toMatchObject({ + choices: [{ + finish_reason: 'length', + }], + }); + + expect(openAiChatTransformer.serializeFinalResponse(failedStop, { + promptTokens: 1, + completionTokens: 1, + totalTokens: 2, + })).toMatchObject({ + choices: [{ + finish_reason: 'stop', + }], + }); + }); + it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => { const normalized = openAiChatTransformer.transformFinalResponse({ id: 'chatcmpl-multi', @@ -481,6 +579,67 @@ describe('openAiChatTransformer.stream', () => { }); }); + it('serializes response.incomplete terminal events with sub2api-like finish reasons', () => { + const context = openAiChatTransformer.createStreamContext('gpt-5'); + + const stopEvent = openAiChatTransformer.transformStreamEvent({ + type: 'response.incomplete', + response: { + id: 'resp-incomplete-stop', + model: 'gpt-5', + status: 'incomplete', + }, + }, context, 'gpt-5'); + + const stopPayloads = parseSsePayloads( + openAiChatTransformer.serializeStreamEvent(stopEvent, context, createClaudeDownstreamContext()), + ); + + expect((stopPayloads[0] as any).choices[0]).toMatchObject({ + finish_reason: 'stop', + }); + + const lengthEvent = openAiChatTransformer.transformStreamEvent({ + type: 'response.incomplete', + response: { + id: 'resp-incomplete-length', + model: 'gpt-5', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + }, + }, context, 'gpt-5'); + + const lengthPayloads = parseSsePayloads( + openAiChatTransformer.serializeStreamEvent(lengthEvent, context, createClaudeDownstreamContext()), + ); + + expect((lengthPayloads[0] as any).choices[0]).toMatchObject({ + finish_reason: 'length', + }); + }); + + it('serializes response.failed terminal events with stop finish_reason instead of error', () => { + const context = openAiChatTransformer.createStreamContext('gpt-5'); + const event = openAiChatTransformer.transformStreamEvent({ + type: 'response.failed', + response: { + id: 'resp-failed-stop', + model: 'gpt-5', + status: 'failed', + }, + }, context, 'gpt-5'); + + const payloads = parseSsePayloads( + openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()), + ); + + expect((payloads[0] as any).choices[0]).toMatchObject({ + finish_reason: 'stop', + }); + }); + it('serializes multi-choice stream chunks without collapsing choice-specific deltas', () => { const context = openAiChatTransformer.createStreamContext('gpt-5'); const event = openAiChatTransformer.transformStreamEvent({ diff --git a/src/server/transformers/openai/chat/index.ts b/src/server/transformers/openai/chat/index.ts index 9191ddc..85ffca9 100644 --- a/src/server/transformers/openai/chat/index.ts +++ b/src/server/transformers/openai/chat/index.ts @@ -1,12 +1,20 @@ -import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js'; import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js'; import { openAiChatInbound } from './inbound.js'; -import { openAiChatOutbound } from './outbound.js'; import { createChatProxyStreamSession } from './proxyStream.js'; -import { openAiChatStream } from './stream.js'; +import { + buildCanonicalRequestToOpenAiChatBody, + parseOpenAiChatRequestToCanonical, +} from './requestBridge.js'; +import { + openAiChatResponseBridge, + buildNormalizedFinalToOpenAiChatChunks, + buildNormalizedFinalToOpenAiChatPayload, + normalizeOpenAiChatFinalToNormalized, +} from './responseBridge.js'; +import { openAiChatStream } from './streamBridge.js'; import { openAiChatUsage } from './usage.js'; import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js'; import type { @@ -17,7 +25,7 @@ import type { export const openAiChatTransformer = { protocol: 'openai/chat' as const, inbound: openAiChatInbound, - outbound: openAiChatOutbound, + outbound: openAiChatResponseBridge, stream: openAiChatStream, usage: openAiChatUsage, compatibility: { @@ -35,41 +43,13 @@ export const openAiChatTransformer = { body: unknown, ctx?: ProtocolParseContext, ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { - const parsed = openAiChatInbound.parse(body); - if (parsed.error) { - return { error: parsed.error }; - } - if (!parsed.value) { - return { - error: { - statusCode: 400, - payload: { - error: { - message: 'invalid chat request', - type: 'invalid_request_error', - }, - }, - }, - }; - } - - return { - value: canonicalRequestFromOpenAiBody({ - body: parsed.value.parsed.upstreamBody, - surface: 'openai-chat', - cliProfile: ctx?.cliProfile, - operation: ctx?.operation, - metadata: ctx?.metadata, - passthrough: ctx?.passthrough, - continuation: ctx?.continuation, - }), - }; + return parseOpenAiChatRequestToCanonical(body, ctx); }, buildProtocolRequest( request: CanonicalRequestEnvelope, _ctx?: ProtocolBuildContext, ): Record { - return canonicalRequestToOpenAiChatBody(request); + return buildCanonicalRequestToOpenAiChatBody(request); }, transformRequest(body: unknown): ReturnType { return openAiChatInbound.parse(body); @@ -78,7 +58,7 @@ export const openAiChatTransformer = { return openAiChatStream.createContext(modelName); }, transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse { - return openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText); + return normalizeOpenAiChatFinalToNormalized(payload, modelName, fallbackText); }, transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent { return openAiChatStream.normalizeEvent(payload, context, modelName); @@ -98,9 +78,9 @@ export const openAiChatTransformer = { }, serializeFinalResponse( normalized: NormalizedFinalResponse, - usage: Parameters[1], + usage: Parameters[1], ) { - return openAiChatOutbound.serializeFinal(normalized, usage); + return buildNormalizedFinalToOpenAiChatPayload(normalized, usage); }, serializeUpstreamFinalAsStream( payload: unknown, @@ -108,16 +88,15 @@ export const openAiChatTransformer = { fallbackText: string, streamContext: StreamTransformContext, ) { - const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText); + const normalizedFinal = normalizeOpenAiChatFinalToNormalized(payload, modelName, fallbackText); streamContext.id = normalizedFinal.id; streamContext.model = normalizedFinal.model; streamContext.created = normalizedFinal.created; - return openAiChatOutbound - .buildSyntheticChunks(normalizedFinal) + return buildNormalizedFinalToOpenAiChatChunks(normalizedFinal) .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`); }, buildSyntheticChunks(normalized: NormalizedFinalResponse) { - return openAiChatOutbound.buildSyntheticChunks(normalized); + return buildNormalizedFinalToOpenAiChatChunks(normalized); }, pullSseEvents(buffer: string) { return openAiChatStream.pullSseEvents(buffer); diff --git a/src/server/transformers/openai/chat/outbound.ts b/src/server/transformers/openai/chat/outbound.ts index c844057..ae2f408 100644 --- a/src/server/transformers/openai/chat/outbound.ts +++ b/src/server/transformers/openai/chat/outbound.ts @@ -1,189 +1,7 @@ -import { - buildSyntheticOpenAiChunks, - normalizeUpstreamFinalResponse, - serializeFinalResponse, - type NormalizedFinalResponse, -} from '../../shared/normalized.js'; -import { extractChatChoices, 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 { - const choices = extractChatChoices(payload); - const primaryChoice = choices[0]; - return { - ...normalizeUpstreamFinalResponse(payload, modelName, fallbackText), - ...(choices.length > 0 ? { choices } : {}), - ...(primaryChoice?.annotations?.length ? { annotations: primaryChoice.annotations } : {}), - ...(primaryChoice?.citations?.length ? { citations: primaryChoice.citations } : {}), - ...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; - - const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0 - ? chatNormalized.choices - : [{ - index: 0, - content: normalized.content, - reasoningContent: normalized.reasoningContent, - toolCalls: normalized.toolCalls, - finishReason: normalized.toolCalls.length > 0 ? 'tool_calls' : normalized.finishReason, - annotations: chatNormalized.annotations, - citations: chatNormalized.citations, - }]; - - payload.choices = choices.map((choice) => { - const message: Record = { - role: choice.role || 'assistant', - content: choice.content, - }; - if (choice.reasoningContent) { - message.reasoning_content = choice.reasoningContent; - } - if (Array.isArray(choice.toolCalls) && choice.toolCalls.length > 0) { - message.tool_calls = choice.toolCalls.map((toolCall) => ({ - id: toolCall.id, - type: 'function', - function: { - name: toolCall.name, - arguments: toolCall.arguments, - }, - })); - if (!choice.content) { - message.content = ''; - } - } - if (Array.isArray(choice.annotations) && choice.annotations.length > 0) { - message.annotations = choice.annotations; - } - - return { - index: choice.index, - message, - finish_reason: choice.finishReason || (choice.toolCalls?.length ? 'tool_calls' : 'stop'), - }; - }).sort((left, right) => left.index - right.index); - - if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) { - payload.citations = chatNormalized.citations; - } - - const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized); - if (mergedUsage) { - payload.usage = { - ...(isRecord(payload.usage) ? payload.usage : {}), - ...mergedUsage, - }; - } - - return payload; - }, - buildSyntheticChunks(normalized: NormalizedFinalResponse) { - const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse; - const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0 - ? chatNormalized.choices - : undefined; - const chunks = choices && choices.length > 1 - ? [ - { - id: normalized.id, - object: 'chat.completion.chunk', - created: normalized.created, - model: normalized.model, - choices: choices.map((choice) => { - const delta: Record = { - role: choice.role || 'assistant', - content: choice.content || '', - }; - if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent; - if (choice.toolCalls.length > 0) { - delta.tool_calls = choice.toolCalls.map((toolCall, toolIndex) => ({ - index: toolIndex, - id: toolCall.id, - type: 'function', - function: { - name: toolCall.name, - arguments: toolCall.arguments, - }, - })); - } - if (Array.isArray(choice.annotations) && choice.annotations.length > 0) { - delta.annotations = choice.annotations; - } - return { - index: choice.index, - delta, - finish_reason: null, - }; - }).sort((left, right) => left.index - right.index), - }, - { - id: normalized.id, - object: 'chat.completion.chunk', - created: normalized.created, - model: normalized.model, - choices: choices.map((choice) => ({ - index: choice.index, - delta: {}, - finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'), - })).sort((left, right) => left.index - right.index), - }, - ] - : buildSyntheticOpenAiChunks(normalized); - if (chunks.length <= 0) return chunks; - - if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) { - chunks[0] = { - ...chunks[0], - citations: chatNormalized.citations, - }; - } - - if (!choices && 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; - }, -}; +export { + openAiChatOutbound, + openAiChatResponseBridge, + buildNormalizedFinalToOpenAiChatChunks, + buildNormalizedFinalToOpenAiChatPayload, + normalizeOpenAiChatFinalToNormalized, +} from './responseBridge.js'; diff --git a/src/server/transformers/openai/chat/proxyStream.ts b/src/server/transformers/openai/chat/proxyStream.ts index a38b50b..56b7bc7 100644 --- a/src/server/transformers/openai/chat/proxyStream.ts +++ b/src/server/transformers/openai/chat/proxyStream.ts @@ -2,8 +2,11 @@ import { anthropicMessagesTransformer } from '../../anthropic/messages/index.js' import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js'; import { type DownstreamFormat, type ParsedSseEvent } from '../../shared/normalized.js'; import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js'; -import { openAiChatOutbound } from './outbound.js'; -import { openAiChatStream } from './stream.js'; +import { + buildNormalizedFinalToOpenAiChatChunks, + normalizeOpenAiChatFinalToNormalized, +} from './responseBridge.js'; +import { openAiChatStream } from './streamBridge.js'; import { config } from '../../../config.js'; type StreamReader = { @@ -50,7 +53,7 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) status: 'completed', errorMessage: null, }; - let terminalNormalizedFinal: ReturnType | null = null; + let terminalNormalizedFinal: ReturnType | null = null; let forwardedDownstreamOutput = false; const pendingWrites: string[] = []; @@ -206,7 +209,7 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values()) .some((choice) => !choice.finishReason); if (needsTerminalFinishChunk) { - const terminalChunk = openAiChatOutbound.buildSyntheticChunks( + const terminalChunk = buildNormalizedFinalToOpenAiChatChunks( finalizeOpenAiChatAggregate(chatAggregateState, { id: streamContext.id, model: streamContext.model, @@ -306,14 +309,13 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) } } if (input.downstreamFormat === 'openai') { - const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText); + const normalizedFinal = normalizeOpenAiChatFinalToNormalized(payload, input.modelName, fallbackText); terminalNormalizedFinal = normalizedFinal; streamContext.id = normalizedFinal.id; streamContext.model = normalizedFinal.model; streamContext.created = normalizedFinal.created; emitLines( - openAiChatOutbound - .buildSyntheticChunks(normalizedFinal) + buildNormalizedFinalToOpenAiChatChunks(normalizedFinal) .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`), { meaningful: true }, ); diff --git a/src/server/transformers/openai/chat/requestBridge.test.ts b/src/server/transformers/openai/chat/requestBridge.test.ts new file mode 100644 index 0000000..e163cb8 --- /dev/null +++ b/src/server/transformers/openai/chat/requestBridge.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCanonicalRequestToOpenAiChatBody, + parseOpenAiChatRequestToCanonical, +} from './requestBridge.js'; + +describe('openai chat request bridge', () => { + it('parses OpenAI Chat bodies into canonical envelopes', () => { + const result = parseOpenAiChatRequestToCanonical({ + model: 'gpt-5', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + prompt_cache_key: 'cache-key', + reasoning_effort: 'high', + reasoning_budget: 1024, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'openai-chat', + requestedModel: 'gpt-5', + continuation: { + promptCacheKey: 'cache-key', + }, + reasoning: { + effort: 'high', + budgetTokens: 1024, + }, + }); + }); + + it('builds OpenAI Chat bodies from canonical envelopes', () => { + const body = buildCanonicalRequestToOpenAiChatBody({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: true, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + reasoning: { + effort: 'medium', + budgetTokens: 512, + }, + continuation: { + promptCacheKey: 'cache-key', + }, + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + prompt_cache_key: 'cache-key', + reasoning_effort: 'medium', + reasoning_budget: 512, + }); + }); + + it('preserves assistant reasoning history alongside tool calls when parsing chat requests', () => { + const result = parseOpenAiChatRequestToCanonical({ + model: 'gpt-5', + stream: false, + messages: [ + { + role: 'assistant', + content: '', + reasoning_content: 'plan quietly', + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + }], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: 'README.md', + }, + ], + }); + + expect(result.error).toBeUndefined(); + expect(result.value?.messages).toEqual([ + { + role: 'assistant', + parts: [ + { + type: 'text', + text: 'plan quietly', + thought: true, + }, + { + type: 'tool_call', + id: 'call_1', + name: 'Glob', + argumentsJson: '{"pattern":"README*"}', + }, + ], + }, + { + role: 'tool', + parts: [{ + type: 'tool_result', + toolCallId: 'call_1', + resultText: 'README.md', + }], + }, + ]); + }); +}); diff --git a/src/server/transformers/openai/chat/requestBridge.ts b/src/server/transformers/openai/chat/requestBridge.ts new file mode 100644 index 0000000..088301f --- /dev/null +++ b/src/server/transformers/openai/chat/requestBridge.ts @@ -0,0 +1,45 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/openAiRequestBridge.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolParseContext } from '../../contracts.js'; +import { openAiChatInbound } from './inbound.js'; + +export function parseOpenAiChatRequestToCanonical( + body: unknown, + ctx?: ProtocolParseContext, +): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = openAiChatInbound.parse(body); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid chat request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + return { + value: canonicalRequestFromOpenAiBody({ + body: parsed.value.parsed.upstreamBody, + surface: 'openai-chat', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; +} + +export function buildCanonicalRequestToOpenAiChatBody( + request: CanonicalRequestEnvelope, +): Record { + return canonicalRequestToOpenAiChatBody(request); +} diff --git a/src/server/transformers/openai/chat/responseBridge.test.ts b/src/server/transformers/openai/chat/responseBridge.test.ts new file mode 100644 index 0000000..e983552 --- /dev/null +++ b/src/server/transformers/openai/chat/responseBridge.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildNormalizedFinalToOpenAiChatChunks, + buildNormalizedFinalToOpenAiChatPayload, + normalizeOpenAiChatFinalToNormalized, + openAiChatResponseBridge, +} from './responseBridge.js'; +import { openAiChatOutbound } from './outbound.js'; + +describe('openai chat response bridge', () => { + it('normalizes final chat payloads into normalized responses', () => { + const normalized = normalizeOpenAiChatFinalToNormalized({ + id: 'chatcmpl-1', + model: 'gpt-5', + created: 123, + choices: [{ + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: 'hello', + reasoning_content: 'think', + }, + }], + }, 'gpt-5'); + + expect(normalized).toMatchObject({ + id: 'chatcmpl-1', + model: 'gpt-5', + content: 'hello', + reasoningContent: 'think', + finishReason: 'stop', + }); + }); + + it('builds final chat payloads and synthetic chunks from normalized responses', () => { + const normalized = { + id: 'chatcmpl-1', + model: 'gpt-5', + created: 123, + content: 'hello', + reasoningContent: 'think', + finishReason: 'stop', + toolCalls: [], + }; + + const payload = buildNormalizedFinalToOpenAiChatPayload(normalized as any, { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }); + const chunks = buildNormalizedFinalToOpenAiChatChunks(normalized as any); + + expect(payload).toMatchObject({ + id: 'chatcmpl-1', + model: 'gpt-5', + usage: { + prompt_tokens: 11, + completion_tokens: 7, + total_tokens: 18, + }, + choices: [{ + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: 'hello', + reasoning_content: 'think', + }, + }], + }); + expect(chunks[0]).toMatchObject({ + id: 'chatcmpl-1', + model: 'gpt-5', + choices: [{ + index: 0, + delta: { + role: 'assistant', + content: 'hello', + }, + }], + }); + }); + + it('preserves tool-call semantics when bridging normalized tool-turn finals back to chat payloads', () => { + const normalized = { + id: 'chatcmpl-tool-1', + model: 'gpt-5', + created: 123, + content: '', + reasoningContent: 'plan first', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }], + }; + + const payload = buildNormalizedFinalToOpenAiChatPayload(normalized as any, { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }); + const chunks = buildNormalizedFinalToOpenAiChatChunks(normalized as any); + + expect(payload).toMatchObject({ + id: 'chatcmpl-tool-1', + model: 'gpt-5', + choices: [{ + index: 0, + finish_reason: 'tool_calls', + message: { + role: 'assistant', + content: '', + reasoning_content: 'plan first', + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + }], + }, + }], + }); + expect(chunks[0]).toMatchObject({ + id: 'chatcmpl-tool-1', + model: 'gpt-5', + choices: [{ + index: 0, + delta: { + role: 'assistant', + content: '', + reasoning_content: 'plan first', + tool_calls: [{ + index: 0, + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + }], + }, + }], + }); + expect(chunks[1]).toMatchObject({ + choices: [{ + index: 0, + finish_reason: 'tool_calls', + }], + }); + }); + + it('keeps the outbound facade pointed at the response bridge object', () => { + expect(openAiChatOutbound).toBe(openAiChatResponseBridge); + }); +}); diff --git a/src/server/transformers/openai/chat/responseBridge.ts b/src/server/transformers/openai/chat/responseBridge.ts new file mode 100644 index 0000000..b507fe8 --- /dev/null +++ b/src/server/transformers/openai/chat/responseBridge.ts @@ -0,0 +1,210 @@ +import { + buildSyntheticOpenAiChunks, + normalizeUpstreamFinalResponse, + serializeFinalResponse, + type NormalizedFinalResponse, +} from '../../shared/normalized.js'; +import { extractChatChoices, 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 function normalizeOpenAiChatFinalToNormalized( + payload: unknown, + modelName: string, + fallbackText = '', +): OpenAiChatNormalizedFinalResponse { + const choices = extractChatChoices(payload); + const primaryChoice = choices[0]; + return { + ...normalizeUpstreamFinalResponse(payload, modelName, fallbackText), + ...(choices.length > 0 ? { choices } : {}), + ...(primaryChoice?.annotations?.length ? { annotations: primaryChoice.annotations } : {}), + ...(primaryChoice?.citations?.length ? { citations: primaryChoice.citations } : {}), + ...extractChatResponseExtras(payload), + }; +} + +export function buildNormalizedFinalToOpenAiChatPayload( + normalized: NormalizedFinalResponse, + usage?: unknown, +) { + const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse; + const payload = serializeFinalResponse( + 'openai', + normalized, + usage as { promptTokens: number; completionTokens: number; totalTokens: number }, + ) as Record; + + const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0 + ? chatNormalized.choices + : [{ + index: 0, + content: normalized.content, + reasoningContent: normalized.reasoningContent, + toolCalls: normalized.toolCalls, + finishReason: normalized.toolCalls.length > 0 ? 'tool_calls' : normalized.finishReason, + annotations: chatNormalized.annotations, + citations: chatNormalized.citations, + }]; + + payload.choices = choices.map((choice) => { + const message: Record = { + role: choice.role || 'assistant', + content: choice.content, + }; + if (choice.reasoningContent) { + message.reasoning_content = choice.reasoningContent; + } + if (Array.isArray(choice.toolCalls) && choice.toolCalls.length > 0) { + message.tool_calls = choice.toolCalls.map((toolCall) => ({ + id: toolCall.id, + type: 'function', + function: { + name: toolCall.name, + arguments: toolCall.arguments, + }, + })); + if (!choice.content) { + message.content = ''; + } + } + if (Array.isArray(choice.annotations) && choice.annotations.length > 0) { + message.annotations = choice.annotations; + } + + return { + index: choice.index, + message, + finish_reason: choice.finishReason || (choice.toolCalls?.length ? 'tool_calls' : 'stop'), + }; + }).sort((left, right) => left.index - right.index); + + if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) { + payload.citations = chatNormalized.citations; + } + + const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized); + if (mergedUsage) { + payload.usage = { + ...(isRecord(payload.usage) ? payload.usage : {}), + ...mergedUsage, + }; + } + + return payload; +} + +export function buildNormalizedFinalToOpenAiChatChunks(normalized: NormalizedFinalResponse) { + const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse; + const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0 + ? chatNormalized.choices + : undefined; + const chunks = choices && choices.length > 1 + ? [ + { + id: normalized.id, + object: 'chat.completion.chunk', + created: normalized.created, + model: normalized.model, + choices: choices.map((choice) => { + const delta: Record = { + role: choice.role || 'assistant', + content: choice.content || '', + }; + if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent; + if (choice.toolCalls.length > 0) { + delta.tool_calls = choice.toolCalls.map((toolCall, toolIndex) => ({ + index: toolIndex, + id: toolCall.id, + type: 'function', + function: { + name: toolCall.name, + arguments: toolCall.arguments, + }, + })); + } + if (Array.isArray(choice.annotations) && choice.annotations.length > 0) { + delta.annotations = choice.annotations; + } + return { + index: choice.index, + delta, + finish_reason: null, + }; + }).sort((left, right) => left.index - right.index), + }, + { + id: normalized.id, + object: 'chat.completion.chunk', + created: normalized.created, + model: normalized.model, + choices: choices.map((choice) => ({ + index: choice.index, + delta: {}, + finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'), + })).sort((left, right) => left.index - right.index), + }, + ] + : buildSyntheticOpenAiChunks(normalized); + if (chunks.length <= 0) return chunks; + + if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) { + chunks[0] = { + ...chunks[0], + citations: chatNormalized.citations, + }; + } + + if (!choices && 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; +} + +export const openAiChatResponseBridge = { + normalizeFinal: normalizeOpenAiChatFinalToNormalized, + serializeFinal: buildNormalizedFinalToOpenAiChatPayload, + buildSyntheticChunks: buildNormalizedFinalToOpenAiChatChunks, +}; + +export const openAiChatOutbound = openAiChatResponseBridge; + +export { + buildNormalizedFinalToOpenAiChatChunks as buildSyntheticOpenAiChatChunksFromNormalized, + buildNormalizedFinalToOpenAiChatPayload as serializeOpenAiChatFinalPayload, + normalizeOpenAiChatFinalToNormalized as normalizeOpenAiChatFinalPayload, +}; diff --git a/src/server/transformers/openai/chat/stream.ts b/src/server/transformers/openai/chat/stream.ts index 58744f6..6d9bdb5 100644 --- a/src/server/transformers/openai/chat/stream.ts +++ b/src/server/transformers/openai/chat/stream.ts @@ -1,190 +1 @@ -import { - createClaudeDownstreamContext, - createStreamTransformContext, - normalizeUpstreamStreamEvent, - pullSseEventsWithDone, - serializeNormalizedStreamEvent, - serializeStreamDone, - type ClaudeDownstreamContext, - type StreamTransformContext, -} from '../../shared/normalized.js'; -import { extractChatChoiceEvents, 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 { - const normalized = normalizeUpstreamStreamEvent(payload, context, modelName); - const choiceEvents = extractChatChoiceEvents(payload); - const primaryChoice = choiceEvents[0]; - const normalizedChoiceEvents = choiceEvents.map((choiceEvent, index) => ( - index === 0 - ? { - ...choiceEvent, - role: normalized.role !== undefined ? normalized.role : choiceEvent.role, - contentDelta: normalized.contentDelta, - reasoningDelta: normalized.reasoningDelta, - toolCallDeltas: normalized.toolCallDeltas !== undefined - ? normalized.toolCallDeltas - : choiceEvent.toolCallDeltas, - finishReason: normalized.finishReason !== undefined - ? normalized.finishReason - : choiceEvent.finishReason, - } - : choiceEvent - )); - return { - ...normalized, - ...(primaryChoice - ? { - choiceIndex: primaryChoice.index, - annotations: primaryChoice.annotations, - citations: primaryChoice.citations, - } - : {}), - ...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}), - ...extractChatResponseExtras(payload), - }; - }, - serializeEvent( - event: OpenAiChatNormalizedStreamEvent, - context: StreamTransformContext, - downstreamContext?: ClaudeDownstreamContext, - ): string[] { - if ( - Array.isArray(event.choiceEvents) - && event.choiceEvents.length > 0 - && (event.choiceEvents.length > 1 || event.choiceEvents[0]?.index !== 0) - ) { - const usagePayload = event.usagePayload - ? { - ...(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 } - : {}), - } - : undefined; - return [`data: ${JSON.stringify({ - id: context.id || `chatcmpl-${Date.now()}`, - object: 'chat.completion.chunk', - created: context.created, - model: context.model, - choices: event.choiceEvents - .map((choiceEvent) => { - const delta: Record = {}; - if (choiceEvent.role) delta.role = choiceEvent.role; - if (choiceEvent.contentDelta !== undefined) delta.content = choiceEvent.contentDelta; - if (choiceEvent.reasoningDelta) delta.reasoning_content = choiceEvent.reasoningDelta; - if (Array.isArray(choiceEvent.toolCallDeltas) && choiceEvent.toolCallDeltas.length > 0) { - delta.tool_calls = choiceEvent.toolCallDeltas.map((toolCall) => ({ - index: toolCall.index, - ...(toolCall.id ? { id: toolCall.id } : {}), - type: 'function', - function: { - ...(toolCall.name ? { name: toolCall.name } : {}), - ...(toolCall.argumentsDelta !== undefined ? { arguments: toolCall.argumentsDelta } : {}), - }, - })); - } - if (Array.isArray(choiceEvent.annotations) && choiceEvent.annotations.length > 0) { - delta.annotations = choiceEvent.annotations; - } - - return { - index: choiceEvent.index, - delta, - finish_reason: choiceEvent.finishReason ?? null, - }; - }) - .sort((left, right) => left.index - right.index), - ...(Array.isArray(event.citations) && event.citations.length > 0 ? { citations: event.citations } : {}), - ...(usagePayload && Object.keys(usagePayload).length > 0 ? { usage: usagePayload } : {}), - })}\n\n`]; - } - - 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); - }, -}; +export { openAiChatStream } from './streamBridge.js'; diff --git a/src/server/transformers/openai/chat/streamBridge.test.ts b/src/server/transformers/openai/chat/streamBridge.test.ts new file mode 100644 index 0000000..a8223d8 --- /dev/null +++ b/src/server/transformers/openai/chat/streamBridge.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; + +import { createClaudeDownstreamContext } from '../../shared/normalized.js'; +import { openAiChatStream } from './streamBridge.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('openai chat stream bridge', () => { + it('normalizes stream payloads with choice metadata', () => { + const context = openAiChatStream.createContext('gpt-5'); + const normalized = openAiChatStream.normalizeEvent({ + id: 'chatcmpl-stream-1', + model: 'gpt-5', + choices: [{ + index: 0, + finish_reason: null, + delta: { + role: 'assistant', + content: 'hello', + reasoning_content: 'think', + }, + }], + }, context, 'gpt-5'); + + expect(normalized).toMatchObject({ + choiceIndex: 0, + role: 'assistant', + contentDelta: 'hello', + reasoningDelta: 'think', + }); + }); + + it('serializes stream events and done markers as openai chat chunks', () => { + const context = openAiChatStream.createContext('gpt-5'); + const lines = openAiChatStream.serializeEvent({ + role: 'assistant', + contentDelta: 'hello', + } as any, context, createClaudeDownstreamContext()); + const doneLines = openAiChatStream.serializeDone(context, createClaudeDownstreamContext()); + + const payloads = parseSsePayloads(lines); + expect(payloads[0]).toMatchObject({ + model: 'gpt-5', + choices: [{ + index: 0, + delta: { + role: 'assistant', + content: 'hello', + }, + }], + }); + expect(doneLines.join('')).toContain('[DONE]'); + }); + + it('round-trips tool-call deltas without flattening them into plain text', () => { + const context = openAiChatStream.createContext('gpt-5'); + const normalized = openAiChatStream.normalizeEvent({ + id: 'chatcmpl-stream-tool-1', + model: 'gpt-5', + choices: [{ + index: 0, + finish_reason: null, + delta: { + role: 'assistant', + tool_calls: [{ + index: 0, + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + }], + }, + }], + }, context, 'gpt-5'); + + expect(normalized).toMatchObject({ + choiceIndex: 0, + choiceEvents: [{ + index: 0, + role: 'assistant', + toolCallDeltas: [{ + index: 0, + id: 'call_1', + name: 'lookup_weather', + argumentsDelta: '{"city":"Shanghai"}', + }], + }], + }); + + const payloads = parseSsePayloads( + openAiChatStream.serializeEvent(normalized, context, createClaudeDownstreamContext()), + ); + + expect(payloads[0]).toMatchObject({ + model: 'gpt-5', + choices: [{ + index: 0, + delta: { + role: 'assistant', + tool_calls: [{ + index: 0, + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + }], + }, + finish_reason: null, + }], + }); + }); + + it('does not replay historical tool identity on later multi-choice argument deltas', () => { + const context = openAiChatStream.createContext('gpt-5'); + + const started = openAiChatStream.normalizeEvent({ + id: 'chatcmpl-stream-multi-tool-1', + model: 'gpt-5', + choices: [ + { + index: 0, + finish_reason: null, + delta: { + role: 'assistant', + content: 'choice-0', + }, + }, + { + index: 1, + finish_reason: null, + delta: { + role: 'assistant', + tool_calls: [{ + index: 0, + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + }, + }], + }, + }, + ], + }, context, 'gpt-5'); + + openAiChatStream.serializeEvent(started, context, createClaudeDownstreamContext()); + + const continued = openAiChatStream.normalizeEvent({ + id: 'chatcmpl-stream-multi-tool-1', + model: 'gpt-5', + choices: [ + { + index: 1, + finish_reason: null, + delta: { + tool_calls: [{ + index: 0, + function: { + arguments: '{"city":"Shanghai"}', + }, + }], + }, + }, + ], + }, context, 'gpt-5'); + + const payloads = parseSsePayloads( + openAiChatStream.serializeEvent(continued, context, createClaudeDownstreamContext()), + ); + const toolCall = ((((payloads[0] as any).choices[0] as any).delta.tool_calls[0]) as Record); + + expect((payloads[0] as any).choices[0]).toMatchObject({ + index: 1, + delta: { + tool_calls: [{ + index: 0, + function: { + arguments: '{"city":"Shanghai"}', + }, + }], + }, + finish_reason: null, + }); + expect(toolCall.id).toBeUndefined(); + expect(toolCall.type).toBeUndefined(); + expect((toolCall.function as Record).name).toBeUndefined(); + }); +}); diff --git a/src/server/transformers/openai/chat/streamBridge.ts b/src/server/transformers/openai/chat/streamBridge.ts new file mode 100644 index 0000000..abbf912 --- /dev/null +++ b/src/server/transformers/openai/chat/streamBridge.ts @@ -0,0 +1,218 @@ +import { + createClaudeDownstreamContext, + createStreamTransformContext, + normalizeUpstreamStreamEvent, + pullSseEventsWithDone, + serializeNormalizedStreamEvent, + serializeStreamDone, + type ClaudeDownstreamContext, + type StreamTransformContext, +} from '../../shared/normalized.js'; +import { extractChatChoiceEvents, extractChatResponseExtras } from './helpers.js'; +import type { OpenAiChatNormalizedStreamEvent } from './model.js'; + +const multiChoiceToolCallState = new WeakMap< + StreamTransformContext, + Record +>(); + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function getMultiChoiceToolState( + context: StreamTransformContext, +): Record { + const existing = multiChoiceToolCallState.get(context); + if (existing) return existing; + const created: Record = {}; + multiChoiceToolCallState.set(context, created); + return created; +} + +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 { + const normalized = normalizeUpstreamStreamEvent(payload, context, modelName); + const choiceEvents = extractChatChoiceEvents(payload); + const primaryChoice = choiceEvents[0]; + const normalizedChoiceEvents = choiceEvents.map((choiceEvent, index) => ( + index === 0 + ? { + ...choiceEvent, + role: normalized.role !== undefined ? normalized.role : choiceEvent.role, + contentDelta: normalized.contentDelta, + reasoningDelta: normalized.reasoningDelta, + toolCallDeltas: normalized.toolCallDeltas !== undefined + ? normalized.toolCallDeltas + : choiceEvent.toolCallDeltas, + finishReason: normalized.finishReason !== undefined + ? normalized.finishReason + : choiceEvent.finishReason, + } + : choiceEvent + )); + return { + ...normalized, + ...(primaryChoice + ? { + choiceIndex: primaryChoice.index, + annotations: primaryChoice.annotations, + citations: primaryChoice.citations, + } + : {}), + ...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}), + ...extractChatResponseExtras(payload), + }; + }, + serializeEvent( + event: OpenAiChatNormalizedStreamEvent, + context: StreamTransformContext, + downstreamContext?: ClaudeDownstreamContext, + ): string[] { + if ( + Array.isArray(event.choiceEvents) + && event.choiceEvents.length > 0 + && (event.choiceEvents.length > 1 || event.choiceEvents[0]?.index !== 0) + ) { + const usagePayload = event.usagePayload + ? { + ...(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 } + : {}), + } + : undefined; + return [`data: ${JSON.stringify({ + id: context.id || `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: context.created, + model: context.model, + choices: event.choiceEvents + .map((choiceEvent) => { + const delta: Record = {}; + if (choiceEvent.role) delta.role = choiceEvent.role; + if (choiceEvent.contentDelta !== undefined) delta.content = choiceEvent.contentDelta; + if (choiceEvent.reasoningDelta) delta.reasoning_content = choiceEvent.reasoningDelta; + if (Array.isArray(choiceEvent.toolCallDeltas) && choiceEvent.toolCallDeltas.length > 0) { + const toolState = getMultiChoiceToolState(context); + delta.tool_calls = choiceEvent.toolCallDeltas.map((toolCall) => { + const toolIndex = Number.isFinite(toolCall.index) ? Math.max(0, Math.trunc(toolCall.index)) : 0; + const stateKey = `${choiceEvent.index}:${toolIndex}`; + const existing = toolState[stateKey] || {}; + toolState[stateKey] = { + ...(toolCall.id || existing.id ? { id: toolCall.id || existing.id } : {}), + ...(toolCall.name || existing.name ? { name: toolCall.name || existing.name } : {}), + arguments: `${existing.arguments || ''}${toolCall.argumentsDelta ?? ''}`, + }; + + const functionPayload: Record = {}; + if (toolCall.name) functionPayload.name = toolCall.name; + if (toolCall.argumentsDelta !== undefined) functionPayload.arguments = toolCall.argumentsDelta; + + return { + index: toolIndex, + ...(toolCall.id ? { id: toolCall.id } : {}), + ...(toolCall.id || toolCall.name ? { type: 'function' } : {}), + ...(Object.keys(functionPayload).length > 0 ? { function: functionPayload } : {}), + }; + }); + } + if (Array.isArray(choiceEvent.annotations) && choiceEvent.annotations.length > 0) { + delta.annotations = choiceEvent.annotations; + } + + return { + index: choiceEvent.index, + delta, + finish_reason: choiceEvent.finishReason ?? null, + }; + }) + .sort((left, right) => left.index - right.index), + ...(Array.isArray(event.citations) && event.citations.length > 0 ? { citations: event.citations } : {}), + ...(usagePayload && Object.keys(usagePayload).length > 0 ? { usage: usagePayload } : {}), + })}\n\n`]; + } + + 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/responses/aggregator.ts b/src/server/transformers/openai/responses/aggregator.ts index ccfe536..a7e2d6d 100644 --- a/src/server/transformers/openai/responses/aggregator.ts +++ b/src/server/transformers/openai/responses/aggregator.ts @@ -1,5 +1,5 @@ import { type StreamTransformContext } from '../../shared/normalized.js'; -import type { OpenAiResponsesStreamEvent } from './stream.js'; +import type { OpenAiResponsesStreamEvent } from './streamBridge.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); diff --git a/src/server/transformers/openai/responses/codexCompatibility.test.ts b/src/server/transformers/openai/responses/codexCompatibility.test.ts index de95671..8a4c7d2 100644 --- a/src/server/transformers/openai/responses/codexCompatibility.test.ts +++ b/src/server/transformers/openai/responses/codexCompatibility.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { normalizeCodexResponsesBodyForProxy } from './codexCompatibility.js'; +const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; + describe('normalizeCodexResponsesBodyForProxy', () => { - it('normalizes codex responses bodies before proxying upstream', () => { + it('extracts codex system input into top-level instructions before proxying upstream', () => { const body = normalizeCodexResponsesBodyForProxy({ input: [ { @@ -10,7 +12,13 @@ describe('normalizeCodexResponsesBodyForProxy', () => { role: 'system', content: [{ type: 'input_text', text: 'be precise' }], }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, ], + instructions: 'keep edits narrow', max_output_tokens: 512, max_completion_tokens: 256, max_tokens: 128, @@ -22,16 +30,28 @@ describe('normalizeCodexResponsesBodyForProxy', () => { input: [ { type: 'message', - role: 'developer', - content: [{ type: 'input_text', text: 'be precise' }], + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], }, ], - instructions: '', + instructions: 'be precise\n\nkeep edits narrow', store: false, temperature: 0.3, }); }); + it('supplies a non-empty default instructions string for codex when missing', () => { + const body = normalizeCodexResponsesBodyForProxy({ + input: 'hello', + }, 'codex'); + + expect(body).toEqual({ + input: 'hello', + instructions: CODEX_DEFAULT_INSTRUCTIONS, + store: false, + }); + }); + it('leaves non-codex bodies untouched', () => { const source = { input: 'hello', diff --git a/src/server/transformers/openai/responses/codexCompatibility.ts b/src/server/transformers/openai/responses/codexCompatibility.ts index 6eff100..4632f48 100644 --- a/src/server/transformers/openai/responses/codexCompatibility.ts +++ b/src/server/transformers/openai/responses/codexCompatibility.ts @@ -6,15 +6,76 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; + +function extractTextFromContent(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((item) => extractTextFromContent(item)) + .filter((item) => item.length > 0) + .join(''); + } + if (!isRecord(content)) return ''; + if (typeof content.text === 'string') return content.text; + if (typeof content.content === 'string') return content.content; + if (typeof content.output_text === 'string') return content.output_text; + if (typeof content.input_text === 'string') return content.input_text; + return ''; +} + +function extractSystemMessagesToInstructions( + body: Record, +): Record { + if (!Array.isArray(body.input) || body.input.length <= 0) return body; + + const extractedSystemTexts: string[] = []; + const remainingInput: unknown[] = []; + + for (const item of body.input) { + if (!isRecord(item)) { + remainingInput.push(item); + continue; + } + if (asTrimmedString(item.type).toLowerCase() !== 'message') { + remainingInput.push(item); + continue; + } + if (asTrimmedString(item.role).toLowerCase() !== 'system') { + remainingInput.push(item); + continue; + } + + const text = extractTextFromContent(item.content); + if (text.trim()) { + extractedSystemTexts.push(text); + } + } + + if (extractedSystemTexts.length <= 0) return body; + + const extractedInstructions = extractedSystemTexts.join('\n\n'); + const existingInstructions = typeof body.instructions === 'string' ? body.instructions : ''; + const nextInstructions = existingInstructions.trim() + ? `${extractedInstructions}\n\n${existingInstructions}` + : extractedInstructions; + + return { + ...body, + input: remainingInput, + instructions: nextInstructions, + }; +} + function ensureCodexResponsesInstructions( body: Record, sitePlatform: string, ): Record { if (sitePlatform !== 'codex') return body; - if (typeof body.instructions === 'string') return body; + if (typeof body.instructions === 'string' && body.instructions.trim()) return body; return { ...body, - instructions: '', + instructions: CODEX_DEFAULT_INSTRUCTIONS, }; } @@ -41,35 +102,12 @@ function stripCodexUnsupportedResponsesFields( return next; } -function convertCodexSystemRoleToDeveloper(input: unknown): unknown { - if (!Array.isArray(input)) return input; - return input.map((item) => { - if (!isRecord(item)) return item; - if (asTrimmedString(item.type).toLowerCase() !== 'message') return item; - if (asTrimmedString(item.role).toLowerCase() !== 'system') return item; - return { - ...item, - role: 'developer', - }; - }); -} - function applyCodexResponsesCompatibility( body: Record, sitePlatform: string, ): Record { if (sitePlatform !== 'codex') return body; - - const next: Record = { - ...body, - input: convertCodexSystemRoleToDeveloper(body.input), - }; - - if (typeof next.instructions !== 'string') { - next.instructions = ''; - } - - return next; + return extractSystemMessagesToInstructions(body); } export function normalizeCodexResponsesBodyForProxy( diff --git a/src/server/transformers/openai/responses/compatibility.ts b/src/server/transformers/openai/responses/compatibility.ts index 960a8a5..2e7d73a 100644 --- a/src/server/transformers/openai/responses/compatibility.ts +++ b/src/server/transformers/openai/responses/compatibility.ts @@ -7,6 +7,10 @@ import { convertOpenAiBodyToResponsesBody, sanitizeResponsesBodyForProxy, } from './conversion.js'; +import { + hasEndpointMismatchHint, + inferRequiredEndpointFromProtocolError, +} from '../../shared/endpointCompatibility.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; @@ -18,11 +22,17 @@ function asTrimmedString(value: unknown): string { export function buildResponsesCompatibilityBodies( body: Record, + options?: { + sitePlatform?: string; + }, ): Record[] { const candidates: Record[] = []; const seen = new Set(); + const useSemanticDedupe = asTrimmedString(options?.sitePlatform).toLowerCase() === 'sub2api'; try { - const originalKey = JSON.stringify(body); + const originalKey = useSemanticDedupe + ? fingerprintCompatibilityValue(body) + : JSON.stringify(body); if (originalKey) seen.add(originalKey); } catch { // ignore non-serializable bodies @@ -32,7 +42,9 @@ export function buildResponsesCompatibilityBodies( if (!next) return; let key = ''; try { - key = JSON.stringify(next); + key = useSemanticDedupe + ? fingerprintCompatibilityValue(next) + : JSON.stringify(next); } catch { return; } @@ -77,19 +89,28 @@ export function buildResponsesCompatibilityBodies( 'background', 'top_logprobs', ] as const; + const normalizedSitePlatform = asTrimmedString(options?.sitePlatform).toLowerCase(); + if (normalizedSitePlatform === 'sub2api' && body.store !== undefined) { + richCandidate.store = cloneJsonValue(body.store); + } for (const key of passthroughFields) { if (body[key] === undefined) continue; richCandidate[key] = cloneJsonValue(body[key]); } push(richCandidate); } - push(buildStrictResponsesBody(body)); + if (!shouldPreserveResponsesCompatibilitySemantics(options?.sitePlatform, body)) { + push(buildStrictResponsesBody(body)); + } return candidates; } export function buildResponsesCompatibilityHeaderCandidates( headers: Record, stream: boolean, + options?: { + sitePlatform?: string; + }, ): Record[] { const candidates: Record[] = []; const seen = new Set(); @@ -106,6 +127,12 @@ export function buildResponsesCompatibilityHeaderCandidates( push(headers); + const normalizedSitePlatform = asTrimmedString(options?.sitePlatform).toLowerCase(); + if (normalizedSitePlatform === 'sub2api') { + push(buildSub2ApiResponsesCompatibilityHeaders(headers, stream)); + return candidates; + } + const minimal: Record = {}; for (const [rawKey, rawValue] of Object.entries(headers)) { const key = rawKey.toLowerCase(); @@ -129,6 +156,7 @@ export function shouldRetryResponsesCompatibility(input: { endpoint: string; status: number; rawErrText: string; + body: Record; }): boolean { if (input.endpoint !== 'responses') return false; if (input.status !== 400) return false; @@ -138,6 +166,7 @@ export function shouldRetryResponsesCompatibility(input: { const message = parsedError.message.trim().toLowerCase(); const compact = `${type} ${code} ${message}`.trim(); const rawCompact = (input.rawErrText || '').toLowerCase(); + const requiredEndpoint = inferRequiredEndpointFromProtocolError(input.rawErrText); if ( compact.includes('invalid_api_key') @@ -150,11 +179,30 @@ export function shouldRetryResponsesCompatibility(input: { return false; } - if (type === 'upstream_error' || code === 'upstream_error') return true; - if (message === 'upstream_error' || message === 'upstream request failed') return true; - if (rawCompact.includes('upstream_error')) return true; + const hasCompatibilityHint = ( + compact.includes('unsupported') + || compact.includes('not supported') + || compact.includes('application/json') + || compact.includes('content-type') + || compact.includes('request validation failed') + || requiredEndpoint !== null + || hasEndpointMismatchHint(input.rawErrText) + || compact.includes('invalid_request_error') + || compact.includes('bad_response_status_code') + || compact.includes('openai_error') + || compact.includes('unsupported legacy protocol') + ); + if (hasCompatibilityHint) return true; - return true; + const compatibilityCandidates = buildResponsesCompatibilityBodies(input.body); + if (compatibilityCandidates.length > 0) { + if (type === 'upstream_error' || code === 'upstream_error') return true; + if (message === 'upstream_error' || message === 'upstream request failed') return true; + if (rawCompact.includes('upstream_error')) return true; + if (compact.length > 0) return true; + } + + return false; } export function shouldDowngradeResponsesChatToMessages( @@ -164,7 +212,7 @@ export function shouldDowngradeResponsesChatToMessages( ): boolean { if (!endpointPath.includes('/chat/completions')) return false; if (status < 400 || status >= 500) return false; - return /messages\s+is\s+required/i.test(upstreamErrorText); + return inferRequiredEndpointFromProtocolError(upstreamErrorText) === 'messages'; } function toFiniteNumber(value: unknown): number | null { @@ -183,6 +231,20 @@ function cloneJsonValue(value: T): T { return value; } +function fingerprintCompatibilityValue(value: unknown): string { + if (value === undefined) return '__undefined__'; + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => fingerprintCompatibilityValue(item)).join(',')}]`; + } + const entries = Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${fingerprintCompatibilityValue(item)}`); + return `{${entries.join(',')}}`; +} + function parseUpstreamErrorShape(rawText: string): { type: string; code: string; @@ -208,6 +270,60 @@ function getExplicitResponsesInstructions(body: Record): string return typeof body.instructions === 'string' ? body.instructions.trim() : ''; } +function shouldPreserveResponsesCompatibilitySemantics( + sitePlatform: string | undefined, + body: Record, +): boolean { + if (asTrimmedString(sitePlatform).toLowerCase() !== 'sub2api') return false; + return [ + 'store', + 'include', + 'reasoning', + 'previous_response_id', + 'truncation', + 'text', + 'service_tier', + 'safety_identifier', + 'max_tool_calls', + 'prompt_cache_key', + 'prompt_cache_retention', + 'background', + 'top_logprobs', + ].some((key) => body[key] !== undefined); +} + +const SUB2API_RESPONSES_COMPATIBILITY_HEADER_ALLOWLIST = new Set([ + 'accept', + 'accept-language', + 'authorization', + 'content-type', + 'conversation-id', + 'conversation_id', + 'openai-beta', + 'originator', + 'session-id', + 'session_id', + 'user-agent', + 'x-api-key', + 'x-codex-turn-metadata', + 'x-codex-turn-state', +]); + +function buildSub2ApiResponsesCompatibilityHeaders( + headers: Record, + stream: boolean, +): Record { + const next: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.toLowerCase(); + if (!SUB2API_RESPONSES_COMPATIBILITY_HEADER_ALLOWLIST.has(key)) continue; + next[key] = rawValue; + } + if (!next['content-type']) next['content-type'] = 'application/json'; + if (stream && !next.accept) next.accept = 'text/event-stream'; + return next; +} + function stripResponsesMetadata( body: Record, ): Record | null { diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index e3206bc..2ed989d 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -7,10 +7,12 @@ import { } from './conversion.js'; import { buildResponsesCompatibilityBodies, + buildResponsesCompatibilityHeaderCandidates, convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaCompatibility, normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility, normalizeResponsesMessageContent as normalizeResponsesMessageContentViaCompatibility, sanitizeResponsesBodyForProxy as sanitizeResponsesBodyForProxyViaCompatibility, + shouldRetryResponsesCompatibility, } from './compatibility.js'; import { normalizeResponsesInputForCompatibility, @@ -704,6 +706,77 @@ describe('convertOpenAiBodyToResponsesBody', () => { expect(result.include).toBeUndefined(); }); + it('does not synthesize max_output_tokens when chat-style input omits all token-limit fields', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + }, + 'gpt-5', + false, + ); + + expect(result.max_output_tokens).toBeUndefined(); + }); + + it('clamps explicit chat token limits to the sub2api floor when converting to Responses bodies', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + }, + 'gpt-5', + false, + ); + + expect(result.max_output_tokens).toBe(128); + }); + + it('uses sub2api-compatible placeholders for empty tool arguments and empty tool outputs', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [ + { + role: 'assistant', + tool_calls: [ + { + id: 'call_empty_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_empty_1', + content: '', + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.input).toEqual([ + { + type: 'function_call', + call_id: 'call_empty_1', + name: 'lookup_weather', + arguments: '{}', + }, + { + type: 'function_call_output', + call_id: 'call_empty_1', + output: '(empty)', + }, + ]); + }); + it('maps OpenAI file-style content blocks into inline-only Responses input_file blocks', () => { const result = convertOpenAiBodyToResponsesBody( { @@ -1568,6 +1641,87 @@ describe('convertResponsesBodyToOpenAiBody', () => { }); }); + it('does not generate extra compatibility bodies for sub2api when responses-only semantics are already present', () => { + const candidates = buildResponsesCompatibilityBodies({ + model: 'gpt-5', + input: 'hello', + stream: true, + store: false, + include: ['reasoning.encrypted_content'], + reasoning: { + effort: 'high', + }, + previous_response_id: 'resp_prev_9', + prompt_cache_key: 'cache-key-9', + service_tier: 'priority', + background: true, + }, { + sitePlatform: 'sub2api', + }); + + expect(candidates).toEqual([]); + }); + + it('builds a sub2api-safe compatibility header candidate that preserves session and codex passthrough headers', () => { + const candidates = buildResponsesCompatibilityHeaderCandidates({ + accept: 'text/event-stream', + authorization: 'Bearer sk-test', + 'content-type': 'application/json', + 'accept-language': 'zh-CN', + 'openai-beta': 'responses-2025-03-11', + originator: 'codex_cli_rs', + 'user-agent': 'client-ua/1.0', + session_id: 'session-123', + conversation_id: 'conversation-123', + 'x-codex-turn-state': 'turn-state', + 'x-codex-turn-metadata': 'turn-metadata', + 'x-stainless-lang': 'typescript', + version: '0.202.0', + }, true, { + sitePlatform: 'sub2api', + }); + + expect(candidates).toContainEqual({ + accept: 'text/event-stream', + authorization: 'Bearer sk-test', + 'content-type': 'application/json', + 'accept-language': 'zh-CN', + 'openai-beta': 'responses-2025-03-11', + originator: 'codex_cli_rs', + 'user-agent': 'client-ua/1.0', + session_id: 'session-123', + conversation_id: 'conversation-123', + 'x-codex-turn-state': 'turn-state', + 'x-codex-turn-metadata': 'turn-metadata', + }); + expect(candidates).not.toContainEqual({ + accept: 'text/event-stream', + authorization: 'Bearer sk-test', + 'content-type': 'application/json', + }); + }); + + it('deduplicates semantically identical sub2api retry bodies even when field order differs', () => { + const candidates = buildResponsesCompatibilityBodies({ + model: 'gpt-5', + input: 'hello', + previous_response_id: 'resp_prev_9', + include: ['reasoning.encrypted_content'], + reasoning: { + effort: 'high', + }, + prompt_cache_key: 'cache-key-9', + service_tier: 'priority', + background: true, + stream: true, + store: false, + }, { + sitePlatform: 'sub2api', + }); + + expect(candidates).toEqual([]); + }); + it('preserves tools and tool_choice across all compatibility retry bodies', () => { const candidates = buildResponsesCompatibilityBodies({ model: 'gpt-5', @@ -1618,6 +1772,39 @@ describe('convertResponsesBodyToOpenAiBody', () => { } }); + it('does not retry auth-shaped failures and still retries generic upstream errors through compatibility variants', () => { + expect(shouldRetryResponsesCompatibility({ + endpoint: 'responses', + status: 400, + rawErrText: JSON.stringify({ + error: { + message: 'invalid_api_key', + type: 'authentication_error', + }, + }), + body: { + model: 'gpt-5', + input: 'hello', + }, + })).toBe(false); + + expect(shouldRetryResponsesCompatibility({ + endpoint: 'responses', + status: 400, + rawErrText: JSON.stringify({ + error: { + message: '', + type: 'upstream_error', + }, + }), + body: { + model: 'gpt-5', + input: 'hello', + metadata: { trace_id: 'req-1' }, + }, + })).toBe(true); + }); + it('maps native tool choices and strict function tools into OpenAI-compatible fallback bodies', () => { const result = convertResponsesBodyToOpenAiBody( { diff --git a/src/server/transformers/openai/responses/conversion.ts b/src/server/transformers/openai/responses/conversion.ts index c7ba482..5adf02d 100644 --- a/src/server/transformers/openai/responses/conversion.ts +++ b/src/server/transformers/openai/responses/conversion.ts @@ -439,6 +439,8 @@ const RESPONSES_COMPATIBILITY_FILTER_FIELDS = new Set([ 'verbosity', ]); +const MIN_RESPONSES_MAX_OUTPUT_TOKENS = 128; + export function sanitizeResponsesBodyForProxy( body: Record, modelName: string, @@ -571,7 +573,7 @@ export function convertOpenAiBodyToResponsesBody( type: 'function_call', call_id: callId, name: getShortToolName(name, toolNameMap), - arguments: argumentsValue, + arguments: argumentsValue || '{}', }); } continue; @@ -580,10 +582,16 @@ export function convertOpenAiBodyToResponsesBody( if (role === 'tool') { const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id); if (!callId) continue; + const output = normalizeToolOutput(item.content); inputItems.push({ type: 'function_call_output', call_id: callId, - output: normalizeToolOutput(item.content), + output: ( + (typeof output === 'string' && output === '') + || (Array.isArray(output) && output.length === 0) + ) + ? '(empty)' + : output, }); continue; } @@ -597,20 +605,25 @@ export function convertOpenAiBodyToResponsesBody( }); } - const maxOutputTokens = ( + const requestedMaxOutputTokens = ( 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 (requestedMaxOutputTokens !== null && requestedMaxOutputTokens > 0) { + body.max_output_tokens = Math.max( + MIN_RESPONSES_MAX_OUTPUT_TOKENS, + Math.trunc(requestedMaxOutputTokens), + ); + } + if (systemContents.length > 0) { body.instructions = systemContents.join('\n\n'); } diff --git a/src/server/transformers/openai/responses/index.test.ts b/src/server/transformers/openai/responses/index.test.ts index 6f0267e..4a8753a 100644 --- a/src/server/transformers/openai/responses/index.test.ts +++ b/src/server/transformers/openai/responses/index.test.ts @@ -72,6 +72,41 @@ describe('openAiResponsesTransformer.inbound', () => { }); }); + it('round-trips continuation turnState through responses metadata bridging', () => { + const built = openAiResponsesTransformer.buildProtocolRequest({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + turnState: 'turn-state-responses-1', + }, + }); + + expect(built).toMatchObject({ + metadata: { + metapi_turn_state: 'turn-state-responses-1', + }, + }); + + const parsed = openAiResponsesTransformer.parseRequest({ + model: 'gpt-5', + input: 'hello', + metadata: { + metapi_turn_state: 'turn-state-responses-1', + }, + }); + + expect(parsed.error).toBeUndefined(); + expect(parsed.value).toMatchObject({ + continuation: { + turnState: 'turn-state-responses-1', + }, + }); + }); + it('returns a protocol request envelope with a normalized responses body', () => { const result = openAiResponsesTransformer.transformRequest({ model: 'gpt-5', diff --git a/src/server/transformers/openai/responses/index.ts b/src/server/transformers/openai/responses/index.ts index 5f93480..5171b7c 100644 --- a/src/server/transformers/openai/responses/index.ts +++ b/src/server/transformers/openai/responses/index.ts @@ -1,4 +1,3 @@ -import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { type StreamTransformContext } from '../../shared/normalized.js'; @@ -23,11 +22,19 @@ import { failResponsesStream, serializeConvertedResponsesEvents, } from './aggregator.js'; -import { openAiResponsesOutbound } from './outbound.js'; import { openAiResponsesInbound } from './inbound.js'; import { createResponsesProxyStreamSession } from './proxyStream.js'; +import { + buildCanonicalRequestToOpenAiResponsesBody, + parseOpenAiResponsesRequestToCanonical, +} from './requestBridge.js'; +import { + openAiResponsesResponseBridge, + buildNormalizedFinalToOpenAiResponsesPayload, + normalizeOpenAiResponsesFinalToNormalized, +} from './responseBridge.js'; import { createResponsesEndpointStrategy } from './routeCompatibility.js'; -import { openAiResponsesStream } from './stream.js'; +import { openAiResponsesStream } from './streamBridge.js'; import { openAiResponsesUsage } from './usage.js'; import type { OpenAiResponsesParsedRequest as OpenAiResponsesParsedRequestModel, @@ -45,7 +52,7 @@ export const openAiResponsesTransformer = { fromOpenAiBody: convertOpenAiBodyToResponsesBody, toOpenAiBody: convertResponsesBodyToOpenAiBody, }, - outbound: openAiResponsesOutbound, + outbound: openAiResponsesResponseBridge, stream: openAiResponsesStream, usage: openAiResponsesUsage, compatibility: { @@ -68,59 +75,13 @@ export const openAiResponsesTransformer = { body: unknown, ctx?: ProtocolParseContext, ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { - const parsed = openAiResponsesInbound.parse(body, { - defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude, - }); - if (parsed.error) { - return { error: parsed.error }; - } - if (!parsed.value) { - return { - error: { - statusCode: 400, - payload: { - error: { - message: 'invalid responses request', - type: 'invalid_request_error', - }, - }, - }, - }; - } - - const responsesBody = parsed.value.parsed.normalizedBody; - const openAiBody = convertResponsesBodyToOpenAiBody( - responsesBody, - typeof responsesBody.model === 'string' ? responsesBody.model : parsed.value.model, - responsesBody.stream === true, - { defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude }, - ); - - return { - value: canonicalRequestFromOpenAiBody({ - body: openAiBody, - surface: 'openai-responses', - cliProfile: ctx?.cliProfile, - operation: ctx?.operation, - metadata: ctx?.metadata, - passthrough: ctx?.passthrough, - continuation: ctx?.continuation, - }), - }; + return parseOpenAiResponsesRequestToCanonical(body, ctx); }, buildProtocolRequest( request: CanonicalRequestEnvelope, _ctx?: ProtocolBuildContext, ): Record { - const openAiBody = canonicalRequestToOpenAiChatBody(request); - if (request.reasoning) { - openAiBody.reasoning = { - ...(request.reasoning.effort ? { effort: request.reasoning.effort } : {}), - ...(request.reasoning.budgetTokens !== undefined ? { budget_tokens: request.reasoning.budgetTokens } : {}), - ...(request.reasoning.summary ? { summary: request.reasoning.summary } : {}), - }; - } - return convertOpenAiBodyToResponsesBody(openAiBody, request.requestedModel, request.stream); + return buildCanonicalRequestToOpenAiResponsesBody(request); }, transformRequest( body: unknown, @@ -132,7 +93,7 @@ export const openAiResponsesTransformer = { return openAiResponsesStream.createContext(modelName); }, transformFinalResponse(payload: unknown, modelName: string, fallbackText = '') { - return openAiResponsesOutbound.normalizeFinal(payload, modelName, fallbackText); + return normalizeOpenAiResponsesFinalToNormalized(payload, modelName, fallbackText); }, transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string) { return openAiResponsesStream.normalizeEvent(payload, context, modelName); @@ -140,6 +101,15 @@ export const openAiResponsesTransformer = { pullSseEvents(buffer: string) { return openAiResponsesStream.pullSseEvents(buffer); }, + serializeFinalResponse( + normalized: Parameters[0]['normalized'], + input: Omit[0], 'normalized'>, + ) { + return buildNormalizedFinalToOpenAiResponsesPayload({ + ...input, + normalized, + }); + }, }; export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer; diff --git a/src/server/transformers/openai/responses/outbound.ts b/src/server/transformers/openai/responses/outbound.ts index 07e34d2..0ca3972 100644 --- a/src/server/transformers/openai/responses/outbound.ts +++ b/src/server/transformers/openai/responses/outbound.ts @@ -1,449 +1,12 @@ -import { - normalizeUpstreamFinalResponse, - type NormalizedFinalResponse, -} from '../../shared/normalized.js'; -import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js'; -import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.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 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 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; - inputTokensDetails?: Record; - outputTokensDetails?: Record; -}; - -export type ResponsesToolCall = { - id: string; - name: string; - arguments: string; -}; - -export type ResponsesFinalSerializationMode = 'response' | 'compact'; - -type ResponsesOutputItem = Record; - -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 []; -} - -function extractToolCallsFromNormalized(normalized: NormalizedFinalResponse): ResponsesToolCall[] { - return Array.isArray(normalized.toolCalls) - ? normalized.toolCalls - .map((item) => { - const id = ensureFunctionCallId(asTrimmedString(item.id)); - const name = asTrimmedString(item.name); - if (!name) return null; - return { - id, - name, - arguments: typeof item.arguments === 'string' ? item.arguments : '', - }; - }) - .filter((item): item is ResponsesToolCall => !!item) - : []; -} - -function extractAnnotationsFromUpstream(payload: unknown): unknown[] { - 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 annotations = Array.isArray((message as any).annotations) ? (message as any).annotations : []; - return annotations.map((item) => cloneJson(item)); - } - - if (payload.type === 'message' && Array.isArray(payload.content)) { - const annotations = payload.content - .filter((item): item is Record => isRecord(item)) - .flatMap((item) => (Array.isArray(item.annotations) ? item.annotations : [])); - return annotations.map((item) => cloneJson(item)); - } - - return []; -} - -function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOutputItem[] { - if (!isRecord(payload) || !Array.isArray(payload.output)) return []; - - return payload.output - .map((rawItem): ResponsesOutputItem | null => { - if (!isRecord(rawItem)) return null; - const itemType = asTrimmedString(rawItem.type).toLowerCase(); - if (!itemType) return null; - - if (itemType === 'message') { - return { - id: asTrimmedString(rawItem.id) || ensureMessageId(''), - type: 'message', - role: asTrimmedString(rawItem.role) || 'assistant', - status: asTrimmedString(rawItem.status) || 'completed', - content: Array.isArray(rawItem.content) ? cloneJson(rawItem.content) : [], - }; - } - - if (itemType === 'reasoning') { - return { - id: asTrimmedString(rawItem.id) || ensureMessageId(''), - type: 'reasoning', - status: asTrimmedString(rawItem.status) || 'completed', - encrypted_content: rawItem.encrypted_content, - summary: Array.isArray(rawItem.summary) ? cloneJson(rawItem.summary) : [], - }; - } - - if (itemType === 'function_call') { - return { - id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''), - type: 'function_call', - status: asTrimmedString(rawItem.status) || 'completed', - call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)), - name: asTrimmedString(rawItem.name), - arguments: typeof rawItem.arguments === 'string' ? rawItem.arguments : '', - }; - } - - if (itemType === 'custom_tool_call') { - return { - id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''), - type: 'custom_tool_call', - status: asTrimmedString(rawItem.status) || 'completed', - call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)), - name: asTrimmedString(rawItem.name), - input: typeof rawItem.input === 'string' ? rawItem.input : '', - }; - } - - if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') { - return { - id: asTrimmedString(rawItem.id) || ensureMessageId(''), - type: itemType, - status: asTrimmedString(rawItem.status) || 'completed', - call_id: asTrimmedString(rawItem.call_id), - output: cloneJson(rawItem.output), - }; - } - - if (itemType === 'image_generation_call') { - return { - id: asTrimmedString(rawItem.id) || ensureMessageId(''), - type: 'image_generation_call', - status: asTrimmedString(rawItem.status) || 'completed', - result: cloneJson(rawItem.result), - partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [], - ...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}), - ...(rawItem.mime_type !== undefined ? { mime_type: cloneJson(rawItem.mime_type) } : {}), - ...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}), - ...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}), - ...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}), - ...(rawItem.revised_prompt !== undefined ? { revised_prompt: cloneJson(rawItem.revised_prompt) } : {}), - }; - } - - if (itemType.startsWith('mcp_')) { - return cloneJson(rawItem); - } - - return null; - }) - .filter((item): item is ResponsesOutputItem => item !== null); -} - -function collectOutputTextFromItems(items: ResponsesOutputItem[]): string { - const textParts: string[] = []; - - for (const item of items) { - 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 partType = asTrimmedString(part.type).toLowerCase(); - if ((partType === 'output_text' || partType === 'text') && typeof part.text === 'string') { - textParts.push(part.text); - } - } - } - - return textParts.join(''); -} - -function extractSyntheticUsageDetails(upstreamPayload: unknown): { - inputTokensDetails?: Record; - outputTokensDetails?: Record; -} { - if (!isRecord(upstreamPayload) || !isRecord(upstreamPayload.usage)) { - return {}; - } - - const usage = upstreamPayload.usage; - const promptDetails = ( - (isRecord(usage.input_tokens_details) ? usage.input_tokens_details : null) - ?? (isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : null) - ); - const completionDetails = ( - (isRecord(usage.output_tokens_details) ? usage.output_tokens_details : null) - ?? (isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : null) - ); - - return { - ...(promptDetails ? { inputTokensDetails: cloneJson(promptDetails) } : {}), - ...(completionDetails ? { outputTokensDetails: cloneJson(completionDetails) } : {}), - }; -} - -export function normalizeResponsesFinalPayload( - payload: unknown, - modelName: string, - fallbackText = '', -): NormalizedFinalResponse { - return normalizeUpstreamFinalResponse(payload, modelName, fallbackText); -} - -function buildResponsesUsagePayload( - usage: ResponsesUsageSummary, - syntheticUsageDetails: { - inputTokensDetails?: Record; - outputTokensDetails?: Record; - }, -): Record { - return { - input_tokens: usage.promptTokens, - output_tokens: usage.completionTokens, - total_tokens: usage.totalTokens, - ...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails - ? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) } - : {}), - ...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails - ? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) } - : {}), - }; -} - -export function serializeResponsesFinalPayload(input: { - upstreamPayload: unknown; - normalized: NormalizedFinalResponse; - usage: ResponsesUsageSummary; - serializationMode?: ResponsesFinalSerializationMode; -}): Record { - const { - upstreamPayload, - normalized, - usage, - serializationMode = 'response', - } = input; - if (isRecord(upstreamPayload)) { - if (upstreamPayload.object === 'response.compaction') { - return upstreamPayload; - } - if (serializationMode === 'response' && 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 syntheticOutput = extractSyntheticOutputItemsFromUpstream(upstreamPayload); - const syntheticUsageDetails = extractSyntheticUsageDetails(upstreamPayload); - const toolCalls = (() => { - const extracted = extractToolCallsFromUpstream(upstreamPayload); - if (extracted.length > 0) return extracted; - return extractToolCallsFromNormalized(normalized); - })(); - const rawReasoningSignature = typeof normalized.reasoningSignature === 'string' - ? normalized.reasoningSignature.trim() - : ''; - const encryptedReasoning = decodeOpenAiEncryptedReasoning(rawReasoningSignature) - ?? (rawReasoningSignature && !rawReasoningSignature.startsWith('metapi:') ? rawReasoningSignature : null); - const annotations = extractAnnotationsFromUpstream(upstreamPayload); - - const output: Array> = syntheticOutput.map((item) => cloneJson(item)); - const hasReasoningItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'reasoning'); - const hasMessageItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'message'); - const hasToolLikeItem = output.some((item) => { - const itemType = asTrimmedString(item.type).toLowerCase(); - return ( - itemType === 'function_call' - || itemType === 'custom_tool_call' - || itemType === 'function_call_output' - || itemType === 'custom_tool_call_output' - || itemType === 'image_generation_call' - ); - }); - - if ((normalized.reasoningContent || encryptedReasoning) && !hasReasoningItem) { - const reasoningItem: Record = { - id: ensureMessageId(`${normalizedId}_reasoning`), - type: 'reasoning', - status: 'completed', - summary: normalized.reasoningContent - ? [{ - type: 'summary_text', - text: normalized.reasoningContent, - }] - : [], - }; - if (encryptedReasoning) { - reasoningItem.encrypted_content = encryptedReasoning; - } - output.push(reasoningItem); - } - - if ((normalized.content || (!hasToolLikeItem && output.length === 0 && toolCalls.length === 0)) && !hasMessageItem) { - const textPart: Record = { - type: 'output_text', - text: normalized.content || '', - }; - if (annotations.length > 0) { - textPart.annotations = annotations; - } - output.push({ - id: messageId, - type: 'message', - role: 'assistant', - status: 'completed', - content: [textPart], - }); - } - - if (toolCalls.length > 0 && !hasToolLikeItem) { - for (const toolCall of toolCalls) { - const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments); - if (mcpItem) { - output.push(mcpItem); - continue; - } - - output.push({ - id: toolCall.id, - type: 'function_call', - status: 'completed', - call_id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - }); - } - } - - const usagePayload = buildResponsesUsagePayload(usage, syntheticUsageDetails); - if (serializationMode === 'compact') { - return { - id: responseId, - object: 'response.compaction', - created_at: normalized.created, - output, - usage: usagePayload, - }; - } - - return { - id: responseId, - object: 'response', - created_at: normalized.created, - status: 'completed', - model: normalized.model, - output, - output_text: normalized.content || collectOutputTextFromItems(output), - usage: usagePayload, - }; -} - -export const openAiResponsesOutbound = { - normalizeFinal: normalizeResponsesFinalPayload, - serializeFinal: serializeResponsesFinalPayload, -}; - +export type { + ResponsesFinalSerializationMode, + ResponsesToolCall, + ResponsesUsageSummary, +} from './responseBridge.js'; export { - serializeResponsesFinalPayload as toResponsesPayload, -}; + openAiResponsesOutbound, + openAiResponsesResponseBridge, + buildNormalizedFinalToOpenAiResponsesPayload as serializeResponsesFinalPayload, + buildNormalizedFinalToOpenAiResponsesPayload as toResponsesPayload, + normalizeOpenAiResponsesFinalToNormalized as normalizeResponsesFinalPayload, +} from './responseBridge.js'; diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts index ae4cd06..bead030 100644 --- a/src/server/transformers/openai/responses/proxyStream.ts +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -1,8 +1,13 @@ import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js'; import { type ParsedSseEvent } from '../../shared/normalized.js'; import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js'; -import { openAiResponsesOutbound } from './outbound.js'; -import { openAiResponsesStream } from './stream.js'; +import { + hasMeaningfulResponsesOutputItem, + hasMeaningfulResponsesPayloadOutput, + openAiResponsesStream, + preserveMeaningfulResponsesTerminalPayload, + serializeResponsesUpstreamFinalAsStream, +} from './streamBridge.js'; import { config } from '../../../config.js'; type StreamReader = { @@ -45,42 +50,8 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function hasNonEmptyString(value: unknown): boolean { - return typeof value === 'string' && value.trim().length > 0; -} - -function hasMeaningfulContentPart(part: unknown): boolean { - if (!isRecord(part)) return false; - const partType = typeof part.type === 'string' ? part.type.trim().toLowerCase() : ''; - if (partType === 'output_text' || partType === 'text') { - return hasNonEmptyString(part.text); - } - return partType.length > 0; -} - -function hasMeaningfulOutputItem(item: unknown): boolean { - if (!isRecord(item)) return false; - const itemType = typeof item.type === 'string' ? item.type.trim().toLowerCase() : ''; - if (itemType === 'message') { - return Array.isArray(item.content) && item.content.some((part) => hasMeaningfulContentPart(part)); - } - if (itemType === 'reasoning') { - return ( - (Array.isArray(item.summary) && item.summary.some((part) => hasMeaningfulContentPart(part))) - || hasNonEmptyString(item.encrypted_content) - ); - } - return itemType.length > 0; -} - -function hasMeaningfulResponsesPayloadOutput(payload: unknown): boolean { - if (!isRecord(payload)) return false; - if (hasNonEmptyString(payload.output_text)) return true; - return Array.isArray(payload.output) && payload.output.some((item) => hasMeaningfulOutputItem(item)); -} - function hasMeaningfulAggregateOutput(state: ReturnType): boolean { - return state.outputItems.some((item) => hasMeaningfulOutputItem(item)); + return state.outputItems.some((item) => hasMeaningfulResponsesOutputItem(item)); } function shouldFailEmptyResponsesCompletion(input: { @@ -116,59 +87,6 @@ function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream return fallback; } -function preserveMeaningfulTerminalResponsesPayload( - lines: string[], - eventType: 'response.completed' | 'response.incomplete', - payload: Record, -): string[] { - const responsePayload = isRecord(payload.response) ? payload.response : null; - if (!responsePayload || !hasMeaningfulResponsesPayloadOutput(responsePayload)) { - return lines; - } - - const parsed = openAiResponsesStream.pullSseEvents(lines.join('')); - let replaced = false; - const nextLines: string[] = []; - - for (const event of parsed.events) { - if (event.data === '[DONE]') { - nextLines.push('data: [DONE]\n\n'); - continue; - } - - let parsedPayload: unknown = null; - try { - parsedPayload = JSON.parse(event.data); - } catch { - nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`); - continue; - } - - if ( - isRecord(parsedPayload) - && asTrimmedString(parsedPayload.type) === eventType - && isRecord(parsedPayload.response) - && !hasMeaningfulResponsesPayloadOutput(parsedPayload.response) - ) { - replaced = true; - nextLines.push( - `event: ${event.event || eventType}\ndata: ${JSON.stringify({ - ...parsedPayload, - response: { - ...parsedPayload.response, - ...responsePayload, - }, - })}\n\n`, - ); - continue; - } - - nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`); - } - - return replaced ? nextLines : lines; -} - export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) { const streamContext = openAiResponsesStream.createContext(input.modelName); const responsesState = createOpenAiResponsesAggregateState(input.modelName); @@ -268,9 +186,9 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes usage: input.getUsage(), }); if (isIncompleteEvent) { - convertedLines = preserveMeaningfulTerminalResponsesPayload(convertedLines, 'response.incomplete', parsedPayload); + convertedLines = preserveMeaningfulResponsesTerminalPayload(convertedLines, 'response.incomplete', parsedPayload); } else if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') { - convertedLines = preserveMeaningfulTerminalResponsesPayload(convertedLines, 'response.completed', parsedPayload); + convertedLines = preserveMeaningfulResponsesTerminalPayload(convertedLines, 'response.completed', parsedPayload); } if ( (eventBlock.event === 'response.completed' || payloadType === 'response.completed') @@ -314,27 +232,22 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes const payloadType = (isRecord(payload) && typeof payload.type === 'string') ? payload.type : ''; - const payloadStatus = isRecord(payload) && typeof payload.status === 'string' - ? payload.status - : ''; if (payloadType === 'error' || payloadType === 'response.failed') { fail(payload); response?.end(); return terminalResult; } - const normalizedFinal = openAiResponsesOutbound.normalizeFinal(payload, input.modelName, fallbackText); + const serializedFinal = serializeResponsesUpstreamFinalAsStream({ + payload, + modelName: input.modelName, + fallbackText, + usage: input.getUsage(), + }); + const { normalizedFinal, streamPayload, isIncompletePayload, lines } = serializedFinal; streamContext.id = normalizedFinal.id; streamContext.model = normalizedFinal.model; streamContext.created = normalizedFinal.created; - - const streamPayload = openAiResponsesOutbound.serializeFinal({ - upstreamPayload: payload, - normalized: normalizedFinal, - usage: input.getUsage(), - serializationMode: 'response', - }); - const isIncompletePayload = payloadType === 'response.incomplete' || payloadStatus === 'incomplete'; if (!isIncompletePayload && shouldFailEmptyResponsesCompletion({ payload: { type: 'response.completed', response: streamPayload }, state: responsesState, @@ -349,36 +262,13 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes response?.end(); return terminalResult; } - const createdPayload = { - ...streamPayload, - status: 'in_progress', - output: [], - output_text: '', - }; - if (isIncompletePayload) { - finalized = true; - terminalResult = { - status: 'completed', - errorMessage: null, - }; - input.writeLines([ - `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, - `event: response.incomplete\ndata: ${JSON.stringify({ type: 'response.incomplete', response: streamPayload })}\n\n`, - 'data: [DONE]\n\n', - ]); - } else { - finalized = true; - terminalResult = { - status: 'completed', - errorMessage: null, - }; - input.writeLines([ - `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, - `event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`, - 'data: [DONE]\n\n', - ]); - } + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + input.writeLines(lines); response?.end(); return terminalResult; }, diff --git a/src/server/transformers/openai/responses/requestBridge.test.ts b/src/server/transformers/openai/responses/requestBridge.test.ts new file mode 100644 index 0000000..5b8233b --- /dev/null +++ b/src/server/transformers/openai/responses/requestBridge.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCanonicalRequestToOpenAiResponsesBody, + parseOpenAiResponsesRequestToCanonical, +} from './requestBridge.js'; + +describe('openai responses request bridge', () => { + it('parses OpenAI Responses bodies into canonical envelopes', () => { + const result = parseOpenAiResponsesRequestToCanonical({ + model: 'gpt-5', + input: 'hello', + previous_response_id: 'resp_prev_1', + prompt_cache_key: 'cache-key', + reasoning: { + effort: 'high', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'openai-responses', + requestedModel: 'gpt-5', + continuation: { + previousResponseId: 'resp_prev_1', + promptCacheKey: 'cache-key', + }, + reasoning: { + effort: 'high', + }, + }); + }); + + it('builds OpenAI Responses bodies from canonical envelopes', () => { + const body = buildCanonicalRequestToOpenAiResponsesBody({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: true, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + previousResponseId: 'resp_prev_1', + promptCacheKey: 'cache-key', + }, + reasoning: { + effort: 'high', + }, + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + stream: true, + store: false, + previous_response_id: 'resp_prev_1', + prompt_cache_key: 'cache-key', + include: ['reasoning.encrypted_content'], + reasoning: { + effort: 'high', + }, + }); + }); + + it('builds Responses tool-turn history from canonical assistant reasoning and tool results', () => { + const body = buildCanonicalRequestToOpenAiResponsesBody({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: false, + messages: [ + { + role: 'assistant', + parts: [ + { + type: 'text', + text: 'plan quietly', + thought: true, + }, + { + type: 'tool_call', + id: 'call_1', + name: 'Glob', + argumentsJson: '{"pattern":"README*"}', + }, + ], + }, + { + role: 'tool', + parts: [{ + type: 'tool_result', + toolCallId: 'call_1', + resultText: 'README.md', + }], + }, + ], + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + store: false, + include: ['reasoning.encrypted_content'], + input: [ + { + type: 'reasoning', + summary: [{ + type: 'summary_text', + text: 'plan quietly', + }], + }, + { + type: 'function_call', + call_id: 'call_1', + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + { + type: 'function_call_output', + call_id: 'call_1', + output: 'README.md', + }, + ], + }); + }); +}); diff --git a/src/server/transformers/openai/responses/requestBridge.ts b/src/server/transformers/openai/responses/requestBridge.ts new file mode 100644 index 0000000..686ff9c --- /dev/null +++ b/src/server/transformers/openai/responses/requestBridge.ts @@ -0,0 +1,87 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/openAiRequestBridge.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolParseContext } from '../../contracts.js'; +import { + convertOpenAiBodyToResponsesBody, + convertResponsesBodyToOpenAiBody, +} from './conversion.js'; +import { openAiResponsesInbound } from './inbound.js'; + +function normalizeIncludeList(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); +} + +export function parseOpenAiResponsesRequestToCanonical( + body: unknown, + ctx?: ProtocolParseContext, +): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = openAiResponsesInbound.parse(body, { + defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude, + }); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid responses request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + const responsesBody = parsed.value.parsed.normalizedBody; + const openAiBody = convertResponsesBodyToOpenAiBody( + responsesBody, + typeof responsesBody.model === 'string' ? responsesBody.model : parsed.value.model, + responsesBody.stream === true, + { defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude }, + ); + + return { + value: canonicalRequestFromOpenAiBody({ + body: openAiBody, + surface: 'openai-responses', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; +} + +export function buildCanonicalRequestToOpenAiResponsesBody( + request: CanonicalRequestEnvelope, +): Record { + const openAiBody = canonicalRequestToOpenAiChatBody(request); + if (request.reasoning) { + openAiBody.reasoning = { + ...(request.reasoning.effort ? { effort: request.reasoning.effort } : {}), + ...(request.reasoning.budgetTokens !== undefined ? { budget_tokens: request.reasoning.budgetTokens } : {}), + ...(request.reasoning.summary ? { summary: request.reasoning.summary } : {}), + }; + } + const body = convertOpenAiBodyToResponsesBody(openAiBody, request.requestedModel, request.stream); + const mergedInclude = Array.from(new Set([ + 'reasoning.encrypted_content', + ...normalizeIncludeList(body.include), + ])); + return { + ...body, + include: mergedInclude, + store: false, + }; +} diff --git a/src/server/transformers/openai/responses/responseBridge.test.ts b/src/server/transformers/openai/responses/responseBridge.test.ts new file mode 100644 index 0000000..a67d838 --- /dev/null +++ b/src/server/transformers/openai/responses/responseBridge.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildNormalizedFinalToOpenAiResponsesPayload, + normalizeOpenAiResponsesFinalToNormalized, + openAiResponsesResponseBridge, +} from './responseBridge.js'; +import { openAiResponsesOutbound } from './outbound.js'; + +describe('openai responses response bridge', () => { + it('normalizes upstream responses finals into normalized final responses', () => { + const normalized = normalizeOpenAiResponsesFinalToNormalized({ + id: 'resp_native_1', + object: 'response', + created_at: 1700000000, + model: 'gpt-5', + output: [ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'hello', + }, + ], + }, + ], + }, 'gpt-5'); + + expect(normalized).toMatchObject({ + id: 'resp_native_1', + model: 'gpt-5', + content: 'hello', + }); + }); + + it('builds responses payloads from normalized final responses', () => { + const payload = buildNormalizedFinalToOpenAiResponsesPayload({ + upstreamPayload: { + id: 'opaque_1', + model: 'gpt-5', + }, + normalized: { + id: 'opaque_1', + model: 'gpt-5', + created: 1700000000, + content: 'hello', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }, + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + }); + + expect(payload).toEqual({ + id: 'resp_opaque_1', + object: 'response', + created_at: 1700000000, + status: 'completed', + model: 'gpt-5', + output: [ + { + id: 'msg_opaque_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'hello', + }, + ], + }, + ], + output_text: 'hello', + usage: { + input_tokens: 11, + output_tokens: 7, + total_tokens: 18, + }, + }); + }); + + it('preserves function-call chains from response-like upstream payloads instead of flattening them into text', () => { + const payload = buildNormalizedFinalToOpenAiResponsesPayload({ + upstreamPayload: { + id: 'opaque_tool_chain_1', + model: 'gpt-5', + output: [ + { + id: 'rs_1', + type: 'reasoning', + status: 'completed', + summary: [ + { + type: 'summary_text', + text: 'plan first', + }, + ], + }, + { + id: 'fc_1', + type: 'function_call', + status: 'completed', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + { + id: 'fco_1', + type: 'function_call_output', + status: 'completed', + call_id: 'call_1', + output: [ + { + type: 'output_text', + text: '22C', + }, + ], + }, + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'done', + }, + ], + }, + ], + }, + normalized: { + id: 'opaque_tool_chain_1', + model: 'gpt-5', + created: 1700000000, + content: 'done', + reasoningContent: 'plan first', + finishReason: 'stop', + toolCalls: [{ + id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }], + }, + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + }); + + expect(payload).toMatchObject({ + id: 'resp_opaque_tool_chain_1', + object: 'response', + output_text: 'done', + output: [ + { + id: 'rs_1', + type: 'reasoning', + status: 'completed', + summary: [ + { + type: 'summary_text', + text: 'plan first', + }, + ], + }, + { + id: 'fc_1', + type: 'function_call', + status: 'completed', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + { + id: 'fco_1', + type: 'function_call_output', + status: 'completed', + call_id: 'call_1', + output: [ + { + type: 'output_text', + text: '22C', + }, + ], + }, + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'done', + }, + ], + }, + ], + }); + }); + + it('keeps the outbound facade pointed at the response bridge object', () => { + expect(openAiResponsesOutbound).toBe(openAiResponsesResponseBridge); + }); +}); diff --git a/src/server/transformers/openai/responses/responseBridge.ts b/src/server/transformers/openai/responses/responseBridge.ts new file mode 100644 index 0000000..858b842 --- /dev/null +++ b/src/server/transformers/openai/responses/responseBridge.ts @@ -0,0 +1,453 @@ +import { + normalizeUpstreamFinalResponse, + type NormalizedFinalResponse, +} from '../../shared/normalized.js'; +import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js'; +import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.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 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 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; + inputTokensDetails?: Record; + outputTokensDetails?: Record; +}; + +export type ResponsesToolCall = { + id: string; + name: string; + arguments: string; +}; + +export type ResponsesFinalSerializationMode = 'response' | 'compact'; + +type ResponsesOutputItem = Record; + +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 []; +} + +function extractToolCallsFromNormalized(normalized: NormalizedFinalResponse): ResponsesToolCall[] { + return Array.isArray(normalized.toolCalls) + ? normalized.toolCalls + .map((item) => { + const id = ensureFunctionCallId(asTrimmedString(item.id)); + const name = asTrimmedString(item.name); + if (!name) return null; + return { + id, + name, + arguments: typeof item.arguments === 'string' ? item.arguments : '', + }; + }) + .filter((item): item is ResponsesToolCall => !!item) + : []; +} + +function extractAnnotationsFromUpstream(payload: unknown): unknown[] { + 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 annotations = Array.isArray((message as any).annotations) ? (message as any).annotations : []; + return annotations.map((item) => cloneJson(item)); + } + + if (payload.type === 'message' && Array.isArray(payload.content)) { + const annotations = payload.content + .filter((item): item is Record => isRecord(item)) + .flatMap((item) => (Array.isArray(item.annotations) ? item.annotations : [])); + return annotations.map((item) => cloneJson(item)); + } + + return []; +} + +function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOutputItem[] { + if (!isRecord(payload) || !Array.isArray(payload.output)) return []; + + return payload.output + .map((rawItem): ResponsesOutputItem | null => { + if (!isRecord(rawItem)) return null; + const itemType = asTrimmedString(rawItem.type).toLowerCase(); + if (!itemType) return null; + + if (itemType === 'message') { + return { + id: asTrimmedString(rawItem.id) || ensureMessageId(''), + type: 'message', + role: asTrimmedString(rawItem.role) || 'assistant', + status: asTrimmedString(rawItem.status) || 'completed', + content: Array.isArray(rawItem.content) ? cloneJson(rawItem.content) : [], + }; + } + + if (itemType === 'reasoning') { + return { + id: asTrimmedString(rawItem.id) || ensureMessageId(''), + type: 'reasoning', + status: asTrimmedString(rawItem.status) || 'completed', + encrypted_content: rawItem.encrypted_content, + summary: Array.isArray(rawItem.summary) ? cloneJson(rawItem.summary) : [], + }; + } + + if (itemType === 'function_call') { + return { + id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''), + type: 'function_call', + status: asTrimmedString(rawItem.status) || 'completed', + call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)), + name: asTrimmedString(rawItem.name), + arguments: typeof rawItem.arguments === 'string' ? rawItem.arguments : '', + }; + } + + if (itemType === 'custom_tool_call') { + return { + id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''), + type: 'custom_tool_call', + status: asTrimmedString(rawItem.status) || 'completed', + call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)), + name: asTrimmedString(rawItem.name), + input: typeof rawItem.input === 'string' ? rawItem.input : '', + }; + } + + if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') { + return { + id: asTrimmedString(rawItem.id) || ensureMessageId(''), + type: itemType, + status: asTrimmedString(rawItem.status) || 'completed', + call_id: asTrimmedString(rawItem.call_id), + output: cloneJson(rawItem.output), + }; + } + + if (itemType === 'image_generation_call') { + return { + id: asTrimmedString(rawItem.id) || ensureMessageId(''), + type: 'image_generation_call', + status: asTrimmedString(rawItem.status) || 'completed', + result: cloneJson(rawItem.result), + partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [], + ...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}), + ...(rawItem.mime_type !== undefined ? { mime_type: cloneJson(rawItem.mime_type) } : {}), + ...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}), + ...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}), + ...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}), + ...(rawItem.revised_prompt !== undefined ? { revised_prompt: cloneJson(rawItem.revised_prompt) } : {}), + }; + } + + if (itemType.startsWith('mcp_')) { + return cloneJson(rawItem); + } + + return null; + }) + .filter((item): item is ResponsesOutputItem => item !== null); +} + +function collectOutputTextFromItems(items: ResponsesOutputItem[]): string { + const textParts: string[] = []; + + for (const item of items) { + 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 partType = asTrimmedString(part.type).toLowerCase(); + if ((partType === 'output_text' || partType === 'text') && typeof part.text === 'string') { + textParts.push(part.text); + } + } + } + + return textParts.join(''); +} + +function extractSyntheticUsageDetails(upstreamPayload: unknown): { + inputTokensDetails?: Record; + outputTokensDetails?: Record; +} { + if (!isRecord(upstreamPayload) || !isRecord(upstreamPayload.usage)) { + return {}; + } + + const usage = upstreamPayload.usage; + const promptDetails = ( + (isRecord(usage.input_tokens_details) ? usage.input_tokens_details : null) + ?? (isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : null) + ); + const completionDetails = ( + (isRecord(usage.output_tokens_details) ? usage.output_tokens_details : null) + ?? (isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : null) + ); + + return { + ...(promptDetails ? { inputTokensDetails: cloneJson(promptDetails) } : {}), + ...(completionDetails ? { outputTokensDetails: cloneJson(completionDetails) } : {}), + }; +} + +export function normalizeOpenAiResponsesFinalToNormalized( + payload: unknown, + modelName: string, + fallbackText = '', +): NormalizedFinalResponse { + return normalizeUpstreamFinalResponse(payload, modelName, fallbackText); +} + +function buildResponsesUsagePayload( + usage: ResponsesUsageSummary, + syntheticUsageDetails: { + inputTokensDetails?: Record; + outputTokensDetails?: Record; + }, +): Record { + return { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + ...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails + ? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) } + : {}), + ...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails + ? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) } + : {}), + }; +} + +export function buildNormalizedFinalToOpenAiResponsesPayload(input: { + upstreamPayload: unknown; + normalized: NormalizedFinalResponse; + usage: ResponsesUsageSummary; + serializationMode?: ResponsesFinalSerializationMode; +}): Record { + const { + upstreamPayload, + normalized, + usage, + serializationMode = 'response', + } = input; + if (isRecord(upstreamPayload)) { + if (upstreamPayload.object === 'response.compaction') { + return upstreamPayload; + } + if (serializationMode === 'response' && 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 syntheticOutput = extractSyntheticOutputItemsFromUpstream(upstreamPayload); + const syntheticUsageDetails = extractSyntheticUsageDetails(upstreamPayload); + const toolCalls = (() => { + const extracted = extractToolCallsFromUpstream(upstreamPayload); + if (extracted.length > 0) return extracted; + return extractToolCallsFromNormalized(normalized); + })(); + const rawReasoningSignature = typeof normalized.reasoningSignature === 'string' + ? normalized.reasoningSignature.trim() + : ''; + const encryptedReasoning = decodeOpenAiEncryptedReasoning(rawReasoningSignature) + ?? (rawReasoningSignature && !rawReasoningSignature.startsWith('metapi:') ? rawReasoningSignature : null); + const annotations = extractAnnotationsFromUpstream(upstreamPayload); + + const output: Array> = syntheticOutput.map((item) => cloneJson(item)); + const hasReasoningItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'reasoning'); + const hasMessageItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'message'); + const hasToolLikeItem = output.some((item) => { + const itemType = asTrimmedString(item.type).toLowerCase(); + return ( + itemType === 'function_call' + || itemType === 'custom_tool_call' + || itemType === 'function_call_output' + || itemType === 'custom_tool_call_output' + || itemType === 'image_generation_call' + ); + }); + + if ((normalized.reasoningContent || encryptedReasoning) && !hasReasoningItem) { + const reasoningItem: Record = { + id: ensureMessageId(`${normalizedId}_reasoning`), + type: 'reasoning', + status: 'completed', + summary: normalized.reasoningContent + ? [{ + type: 'summary_text', + text: normalized.reasoningContent, + }] + : [], + }; + if (encryptedReasoning) { + reasoningItem.encrypted_content = encryptedReasoning; + } + output.push(reasoningItem); + } + + if ((normalized.content || (!hasToolLikeItem && output.length === 0 && toolCalls.length === 0)) && !hasMessageItem) { + const textPart: Record = { + type: 'output_text', + text: normalized.content || '', + }; + if (annotations.length > 0) { + textPart.annotations = annotations; + } + output.push({ + id: messageId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [textPart], + }); + } + + if (toolCalls.length > 0 && !hasToolLikeItem) { + for (const toolCall of toolCalls) { + const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments); + if (mcpItem) { + output.push(mcpItem); + continue; + } + + output.push({ + id: toolCall.id, + type: 'function_call', + status: 'completed', + call_id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + }); + } + } + + const usagePayload = buildResponsesUsagePayload(usage, syntheticUsageDetails); + if (serializationMode === 'compact') { + return { + id: responseId, + object: 'response.compaction', + created_at: normalized.created, + output, + usage: usagePayload, + }; + } + + return { + id: responseId, + object: 'response', + created_at: normalized.created, + status: 'completed', + model: normalized.model, + output, + output_text: normalized.content || collectOutputTextFromItems(output), + usage: usagePayload, + }; +} + +export { + normalizeOpenAiResponsesFinalToNormalized as normalizeResponsesFinalPayload, + buildNormalizedFinalToOpenAiResponsesPayload as serializeResponsesFinalPayload, + buildNormalizedFinalToOpenAiResponsesPayload as toResponsesPayload, +}; + +export const openAiResponsesResponseBridge = { + normalizeFinal: normalizeOpenAiResponsesFinalToNormalized, + serializeFinal: buildNormalizedFinalToOpenAiResponsesPayload, +}; + +export const openAiResponsesOutbound = openAiResponsesResponseBridge; diff --git a/src/server/transformers/openai/responses/routeCompatibility.ts b/src/server/transformers/openai/responses/routeCompatibility.ts index a3d1c4c..88aab92 100644 --- a/src/server/transformers/openai/responses/routeCompatibility.ts +++ b/src/server/transformers/openai/responses/routeCompatibility.ts @@ -38,6 +38,7 @@ type UpstreamResponse = Exclude['upstream']; type CreateResponsesEndpointStrategyInput = { isStream: boolean; requiresNativeResponsesFileUrl: boolean; + sitePlatform?: string; dispatchRequest: ( request: CompatibilityRequest, targetUrl?: string, @@ -51,12 +52,47 @@ export function createResponsesEndpointStrategy(input: CreateResponsesEndpointSt endpoint: ctx.request.endpoint, status: ctx.response.status, rawErrText: ctx.rawErrText, + body: ctx.request.body, })) { - const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body); + const normalizedSitePlatform = String(input.sitePlatform || '').trim().toLowerCase(); + const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body, { + sitePlatform: input.sitePlatform, + }); const compatibilityHeaders = buildResponsesCompatibilityHeaderCandidates( ctx.request.headers, input.isStream, + { + sitePlatform: input.sitePlatform, + }, ); + const alternateCompatibilityHeaders = compatibilityHeaders.slice(1); + + if ( + normalizedSitePlatform === 'sub2api' + && compatibilityBodies.length === 0 + && alternateCompatibilityHeaders.length > 0 + ) { + for (const compatibilityHeadersCandidate of alternateCompatibilityHeaders) { + const compatibilityRequest = { + ...ctx.request, + headers: compatibilityHeadersCandidate, + }; + const compatibilityResponse = await input.dispatchRequest( + compatibilityRequest, + ctx.targetUrl, + ); + if (compatibilityResponse.ok) { + return { + upstream: compatibilityResponse, + upstreamPath: compatibilityRequest.path, + }; + } + + ctx.request = compatibilityRequest; + ctx.response = compatibilityResponse; + ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error'); + } + } for (const compatibilityHeadersCandidate of compatibilityHeaders) { for (const compatibilityBody of compatibilityBodies) { diff --git a/src/server/transformers/openai/responses/stream.ts b/src/server/transformers/openai/responses/stream.ts index 4e50bc3..abbe926 100644 --- a/src/server/transformers/openai/responses/stream.ts +++ b/src/server/transformers/openai/responses/stream.ts @@ -1,81 +1,11 @@ -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.incomplete', - '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 type { + OpenAiResponsesStreamEvent, + ResponsesStreamState, +} from './streamBridge.js'; export { completeResponsesStream, + createResponsesStreamState, failResponsesStream, + openAiResponsesStream, serializeConvertedResponsesEvents, -}; +} from './streamBridge.js'; diff --git a/src/server/transformers/openai/responses/streamBridge.test.ts b/src/server/transformers/openai/responses/streamBridge.test.ts new file mode 100644 index 0000000..c107250 --- /dev/null +++ b/src/server/transformers/openai/responses/streamBridge.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, it } from 'vitest'; + +import { + openAiResponsesStream, + preserveMeaningfulResponsesTerminalPayload, + serializeResponsesUpstreamFinalAsStream, +} from './streamBridge.js'; + +function parseSseEvents(lines: string[]): Array<{ event: string | null; payload: Record | '[DONE]' }> { + return lines + .flatMap((line) => line.split('\n\n').filter((block) => block.trim().length > 0)) + .map((block) => { + const eventLine = block + .split('\n') + .find((line) => line.startsWith('event: ')); + const dataLine = block + .split('\n') + .find((line) => line.startsWith('data: ')); + if (!dataLine) return null; + if (dataLine === 'data: [DONE]') { + return { + event: eventLine ? eventLine.slice('event: '.length) : null, + payload: '[DONE]' as const, + }; + } + + return { + event: eventLine ? eventLine.slice('event: '.length) : null, + payload: JSON.parse(dataLine.slice('data: '.length)) as Record, + }; + }) + .filter((item): item is { event: string | null; payload: Record | '[DONE]' } => !!item); +} + +describe('openai responses stream bridge', () => { + it('normalizes response stream payload metadata onto normalized events', () => { + const context = openAiResponsesStream.createContext('gpt-5'); + const payload = { + type: 'response.completed', + response: { + id: 'resp_1', + status: 'completed', + }, + usage: { + input_tokens: 11, + output_tokens: 7, + total_tokens: 18, + }, + }; + + const normalized = openAiResponsesStream.normalizeEvent(payload, context, 'gpt-5'); + + expect(normalized.responsesEventType).toBe('response.completed'); + expect(normalized.responsesPayload).toEqual(payload); + expect(normalized.usagePayload).toEqual(payload.usage); + }); + + it('pulls response SSE events and preserves the DONE terminator', () => { + const parsed = openAiResponsesStream.pullSseEvents([ + 'event: response.completed', + 'data: {"type":"response.completed"}', + '', + 'data: [DONE]', + '', + '', + ].join('\n')); + + expect(parsed.events).toEqual([ + { + event: 'response.completed', + data: '{"type":"response.completed"}', + }, + { + event: '', + data: '[DONE]', + }, + ]); + expect(parsed.rest).toBe(''); + }); + + it('serializes upstream final payloads into response.created and response.completed SSE lines', () => { + const serialized = serializeResponsesUpstreamFinalAsStream({ + payload: { + id: 'resp_fallback_1', + object: 'response', + status: 'completed', + model: 'gpt-5', + output_text: 'hello from fallback', + output: [ + { + id: 'msg_fallback_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from fallback' }], + }, + ], + }, + modelName: 'gpt-5', + fallbackText: 'hello from fallback', + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + }); + + expect(serialized.isIncompletePayload).toBe(false); + expect(serialized.lines.join('')).toContain('event: response.created'); + expect(serialized.lines.join('')).toContain('event: response.completed'); + expect(serialized.lines.join('')).toContain('"output_text":"hello from fallback"'); + expect(serialized.lines.join('')).toContain('data: [DONE]'); + }); + + it('serializes fallback final tool and reasoning output items as canonical Responses SSE events before completion', () => { + const serialized = serializeResponsesUpstreamFinalAsStream({ + payload: { + id: 'resp_semantic_fallback_1', + object: 'response', + status: 'completed', + model: 'gpt-5', + output_text: 'final answer', + output: [ + { + id: 'rs_1', + type: 'reasoning', + status: 'completed', + summary: [{ type: 'summary_text', text: 'plan quietly' }], + }, + { + id: 'call_1', + type: 'function_call', + status: 'completed', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'final answer' }], + }, + ], + }, + modelName: 'gpt-5', + fallbackText: 'final answer', + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + }); + + const events = parseSseEvents(serialized.lines); + + expect(events.map((entry) => entry.event)).toEqual([ + 'response.created', + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.output_item.done', + 'response.function_call_arguments.done', + 'response.output_item.done', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + null, + ]); + expect(events[4]?.payload).toMatchObject({ + type: 'response.function_call_arguments.done', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }); + expect(events[9]?.payload).toMatchObject({ + type: 'response.completed', + response: { + id: 'resp_semantic_fallback_1', + output_text: 'final answer', + output: [ + { + id: 'rs_1', + type: 'reasoning', + }, + { + id: 'call_1', + type: 'function_call', + call_id: 'call_1', + arguments: '{"city":"Shanghai"}', + }, + { + id: 'msg_1', + type: 'message', + }, + ], + }, + }); + expect(events[10]?.payload).toBe('[DONE]'); + }); + + it('preserves meaningful terminal response payload output when the synthesized terminal event is empty', () => { + const preserved = preserveMeaningfulResponsesTerminalPayload( + [ + 'event: response.completed\n', + `data: ${JSON.stringify({ + type: 'response.completed', + response: { + id: 'resp_empty', + status: 'completed', + output: [], + output_text: '', + }, + })}\n\n`, + 'data: [DONE]\n\n', + ], + 'response.completed', + { + type: 'response.completed', + response: { + id: 'resp_full', + status: 'completed', + output: [ + { + id: 'msg_full', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'restored text' }], + }, + ], + output_text: 'restored text', + }, + }, + ); + + expect(preserved.join('')).toContain('"output_text":"restored text"'); + expect(preserved.join('')).toContain('data: [DONE]'); + }); + + it('keeps response tool-call chains intact in completed SSE fallback payloads', () => { + const serialized = serializeResponsesUpstreamFinalAsStream({ + payload: { + id: 'resp_tool_stream_1', + object: 'response', + status: 'completed', + model: 'gpt-5', + output_text: 'done', + output: [ + { + id: 'fc_1', + type: 'function_call', + status: 'completed', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + { + id: 'fco_1', + type: 'function_call_output', + status: 'completed', + call_id: 'call_1', + output: '22C', + }, + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'done' }], + }, + ], + }, + modelName: 'gpt-5', + fallbackText: 'done', + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + }); + + expect(serialized.lines.join('')).toContain('event: response.completed'); + expect(serialized.lines.join('')).toContain('"type":"function_call"'); + expect(serialized.lines.join('')).toContain('"type":"function_call_output"'); + expect(serialized.lines.join('')).toContain('"call_id":"call_1"'); + expect(serialized.lines.join('')).toContain('"output_text":"done"'); + }); + + it('keeps incomplete fallback item statuses aligned with the terminal response status when item statuses are omitted', () => { + const serialized = serializeResponsesUpstreamFinalAsStream({ + payload: { + id: 'resp_incomplete_1', + object: 'response', + status: 'incomplete', + model: 'gpt-5', + output_text: 'partial', + output: [ + { + id: 'msg_partial_1', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'partial' }], + }, + ], + }, + modelName: 'gpt-5', + fallbackText: 'partial', + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + }); + + const events = parseSseEvents(serialized.lines); + + expect(events.map((entry) => entry.event)).toEqual([ + 'response.created', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.incomplete', + null, + ]); + expect(events[3]?.payload).toMatchObject({ + type: 'response.output_item.done', + item: { + id: 'msg_partial_1', + type: 'message', + status: 'incomplete', + }, + }); + expect(events[4]?.payload).toMatchObject({ + type: 'response.incomplete', + response: { + id: 'resp_incomplete_1', + status: 'incomplete', + output: [ + { + id: 'msg_partial_1', + type: 'message', + status: 'incomplete', + }, + ], + }, + }); + }); + + it('serializes failed fallback wrappers as response.failed and preserves the upstream error payload', () => { + const serialized = serializeResponsesUpstreamFinalAsStream({ + payload: { + type: 'response.failed', + error: { + message: 'upstream exploded', + type: 'upstream_error', + }, + response: { + id: 'resp_failed_1', + status: 'failed', + model: 'gpt-5', + output_text: 'partial', + output: [ + { + id: 'msg_failed_1', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'partial' }], + }, + ], + }, + }, + modelName: 'gpt-5', + fallbackText: 'partial', + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + }); + + const events = parseSseEvents(serialized.lines); + + expect(events.map((entry) => entry.event)).toEqual([ + 'response.created', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.failed', + null, + ]); + expect(events[3]?.payload).toMatchObject({ + type: 'response.output_item.done', + item: { + id: 'msg_failed_1', + type: 'message', + status: 'failed', + }, + }); + expect(events[4]?.payload).toMatchObject({ + type: 'response.failed', + error: { + message: 'upstream exploded', + type: 'upstream_error', + }, + response: { + id: 'resp_failed_1', + status: 'failed', + output: [ + { + id: 'msg_failed_1', + type: 'message', + status: 'failed', + }, + ], + }, + }); + }); +}); diff --git a/src/server/transformers/openai/responses/streamBridge.ts b/src/server/transformers/openai/responses/streamBridge.ts new file mode 100644 index 0000000..af0c95d --- /dev/null +++ b/src/server/transformers/openai/responses/streamBridge.ts @@ -0,0 +1,305 @@ +import { + createStreamTransformContext, + normalizeUpstreamStreamEvent, + pullSseEventsWithDone, + type NormalizedStreamEvent, + type StreamTransformContext, +} from '../../shared/normalized.js'; +import { + completeResponsesStream, + createOpenAiResponsesAggregateState, + failResponsesStream, + serializeConvertedResponsesEvents, + type OpenAiResponsesAggregateState, +} from './aggregator.js'; +import { + buildNormalizedFinalToOpenAiResponsesPayload, + normalizeOpenAiResponsesFinalToNormalized, + type ResponsesUsageSummary, +} from './responseBridge.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 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 hasNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0; +} + +function hasMeaningfulContentPart(part: unknown): boolean { + if (!isRecord(part)) return false; + const partType = asTrimmedString(part.type).toLowerCase(); + if (partType === 'output_text' || partType === 'text') { + return hasNonEmptyString(part.text); + } + return partType.length > 0; +} + +export function hasMeaningfulResponsesOutputItem(item: unknown): boolean { + if (!isRecord(item)) return false; + const itemType = asTrimmedString(item.type).toLowerCase(); + if (itemType === 'message') { + return Array.isArray(item.content) && item.content.some((part) => hasMeaningfulContentPart(part)); + } + if (itemType === 'reasoning') { + return ( + (Array.isArray(item.summary) && item.summary.some((part) => hasMeaningfulContentPart(part))) + || hasNonEmptyString(item.encrypted_content) + ); + } + return itemType.length > 0; +} + +export function hasMeaningfulResponsesPayloadOutput(payload: unknown): boolean { + if (!isRecord(payload)) return false; + if (hasNonEmptyString(payload.output_text)) return true; + return Array.isArray(payload.output) && payload.output.some((item) => hasMeaningfulResponsesOutputItem(item)); +} + +export function preserveMeaningfulResponsesTerminalPayload( + lines: string[], + eventType: 'response.completed' | 'response.incomplete', + payload: Record, +): string[] { + const responsePayload = isRecord(payload.response) ? payload.response : null; + if (!responsePayload || !hasMeaningfulResponsesPayloadOutput(responsePayload)) { + return lines; + } + + const parsed = openAiResponsesStream.pullSseEvents(lines.join('')); + let replaced = false; + const nextLines: string[] = []; + + for (const event of parsed.events) { + if (event.data === '[DONE]') { + nextLines.push('data: [DONE]\n\n'); + continue; + } + + let parsedPayload: unknown = null; + try { + parsedPayload = JSON.parse(event.data); + } catch { + nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`); + continue; + } + + if ( + isRecord(parsedPayload) + && asTrimmedString(parsedPayload.type) === eventType + && isRecord(parsedPayload.response) + && !hasMeaningfulResponsesPayloadOutput(parsedPayload.response) + ) { + replaced = true; + nextLines.push( + `event: ${event.event || eventType}\ndata: ${JSON.stringify({ + ...parsedPayload, + response: { + ...parsedPayload.response, + ...responsePayload, + }, + })}\n\n`, + ); + continue; + } + + nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`); + } + + return replaced ? nextLines : lines; +} + +export function serializeResponsesUpstreamFinalAsStream(input: { + payload: unknown; + modelName: string; + fallbackText: string; + usage: ResponsesUsageSummary; +}): { + normalizedFinal: ReturnType; + streamPayload: Record; + createdPayload: Record; + isIncompletePayload: boolean; + lines: string[]; +} { + const { payload, modelName, fallbackText, usage } = input; + const normalizedFinal = normalizeOpenAiResponsesFinalToNormalized(payload, modelName, fallbackText); + const streamPayload = buildNormalizedFinalToOpenAiResponsesPayload({ + upstreamPayload: payload, + normalized: normalizedFinal, + usage, + serializationMode: 'response', + }); + const payloadType = (isRecord(payload) && typeof payload.type === 'string') + ? payload.type + : ''; + const payloadStatus = isRecord(payload) && typeof payload.status === 'string' + ? payload.status + : ''; + const isFailedPayload = payloadType === 'response.failed' || payloadStatus === 'failed'; + const isIncompletePayload = payloadType === 'response.incomplete' || payloadStatus === 'incomplete'; + const terminalStatus = isFailedPayload ? 'failed' : (isIncompletePayload ? 'incomplete' : 'completed'); + const upstreamTerminalResponse = isRecord(payload) && isRecord(payload.response) + ? cloneJson(payload.response) + : null; + const terminalResponsePayload = upstreamTerminalResponse + ? { + ...streamPayload, + ...upstreamTerminalResponse, + ...(Array.isArray(upstreamTerminalResponse.output) ? { output: cloneJson(upstreamTerminalResponse.output) } : {}), + ...(typeof upstreamTerminalResponse.output_text === 'string' + ? { output_text: upstreamTerminalResponse.output_text } + : {}), + status: terminalStatus, + } + : { + ...streamPayload, + status: terminalStatus, + }; + const createdPayload = { + ...terminalResponsePayload, + status: 'in_progress', + output: [], + output_text: '', + }; + + const terminalEventType = isFailedPayload + ? 'response.failed' + : (isIncompletePayload ? 'response.incomplete' : 'response.completed'); + const fallbackState = createOpenAiResponsesAggregateState(normalizedFinal.model || modelName); + const fallbackContext = createStreamTransformContext(normalizedFinal.model || modelName); + fallbackContext.id = normalizedFinal.id; + fallbackContext.model = normalizedFinal.model; + fallbackContext.created = normalizedFinal.created; + + const lines = [ + `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, + ]; + + const outputItems = Array.isArray(terminalResponsePayload.output) ? terminalResponsePayload.output : []; + for (let outputIndex = 0; outputIndex < outputItems.length; outputIndex += 1) { + const item = outputItems[outputIndex]; + if (!isRecord(item)) continue; + const eventItem = cloneJson(item); + const itemStatus = asTrimmedString(eventItem.status).toLowerCase(); + if ((!itemStatus || itemStatus === 'in_progress') && terminalStatus !== 'completed') { + eventItem.status = terminalStatus; + } + lines.push(...serializeConvertedResponsesEvents({ + state: fallbackState, + streamContext: fallbackContext, + usage, + event: { + responsesEventType: 'response.output_item.done', + responsesPayload: { + type: 'response.output_item.done', + output_index: outputIndex, + item: eventItem, + }, + }, + })); + } + + lines.push(...serializeConvertedResponsesEvents({ + state: fallbackState, + streamContext: fallbackContext, + usage, + event: { + responsesEventType: terminalEventType, + responsesPayload: { + type: terminalEventType, + response: terminalResponsePayload, + ...(terminalEventType === 'response.failed' && isRecord(payload) && payload.error !== undefined + ? { error: cloneJson(payload.error) } + : {}), + }, + }, + })); + lines.push('data: [DONE]\n\n'); + + return { + normalizedFinal, + streamPayload, + createdPayload, + isIncompletePayload, + lines, + }; +} + +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.incomplete', + '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/shared/chatFormatsCore.test.ts b/src/server/transformers/shared/chatFormatsCore.test.ts index b9d8a27..1c1f794 100644 --- a/src/server/transformers/shared/chatFormatsCore.test.ts +++ b/src/server/transformers/shared/chatFormatsCore.test.ts @@ -4,6 +4,7 @@ import { convertClaudeRequestToOpenAiBody, createClaudeDownstreamContext, createStreamTransformContext, + normalizeUpstreamFinalResponse, normalizeUpstreamStreamEvent, serializeNormalizedStreamEvent, } from './chatFormatsCore.js'; @@ -338,6 +339,50 @@ describe('chatFormatsCore inline think parsing', () => { }); }); + it('maps response.incomplete to stop unless the incomplete reason is max_output_tokens', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.incomplete', + response: { + id: 'resp_incomplete_stop', + status: 'incomplete', + }, + }, context, 'gpt-test')).toEqual({ + finishReason: 'stop', + done: true, + }); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.incomplete', + response: { + id: 'resp_incomplete_length', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + }, + }, context, 'gpt-test')).toEqual({ + finishReason: 'length', + done: true, + }); + }); + + it('maps response.failed to a stop finish reason instead of inventing a chat error finish reason', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + type: 'response.failed', + response: { + id: 'resp_failed_stop', + status: 'failed', + }, + }, context, 'gpt-test')).toEqual({ + finishReason: 'stop', + done: true, + }); + }); + it('does not backfill historical tool identity into later arguments-only deltas', () => { const context = createStreamTransformContext('gpt-test'); const claudeContext = createClaudeDownstreamContext(); @@ -571,6 +616,38 @@ describe('chatFormatsCore inline think parsing', () => { }], }); }); + + it('maps final Responses payload statuses to sub2api-like chat finish reasons', () => { + expect(normalizeUpstreamFinalResponse({ + id: 'resp_final_stop', + object: 'response', + status: 'incomplete', + output: [], + }, 'gpt-test')).toMatchObject({ + finishReason: 'stop', + }); + + expect(normalizeUpstreamFinalResponse({ + id: 'resp_final_length', + object: 'response', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + output: [], + }, 'gpt-test')).toMatchObject({ + finishReason: 'length', + }); + + expect(normalizeUpstreamFinalResponse({ + id: 'resp_final_failed', + object: 'response', + status: 'failed', + output: [], + }, 'gpt-test')).toMatchObject({ + finishReason: 'stop', + }); + }); }); describe('convertClaudeRequestToOpenAiBody', () => { diff --git a/src/server/transformers/shared/chatFormatsCore.ts b/src/server/transformers/shared/chatFormatsCore.ts index b01e512..6741d3e 100644 --- a/src/server/transformers/shared/chatFormatsCore.ts +++ b/src/server/transformers/shared/chatFormatsCore.ts @@ -493,6 +493,29 @@ function parseResponsesReasoning(payload: Record): { }; } +function responsesStatusToChatFinishReason( + status: unknown, + incompleteDetails: unknown, + hasToolCalls: boolean, +): string { + const normalizedStatus = asTrimmedString(status).toLowerCase(); + if (normalizedStatus === 'completed') { + return hasToolCalls ? 'tool_calls' : 'stop'; + } + if (normalizedStatus === 'incomplete') { + const reason = ( + isRecord(incompleteDetails) + ? asTrimmedString(incompleteDetails.reason).toLowerCase() + : '' + ); + return reason === 'max_output_tokens' ? 'length' : 'stop'; + } + if (normalizedStatus === 'failed') { + return 'stop'; + } + return hasToolCalls ? 'tool_calls' : 'stop'; +} + function unwrapTerminalResponsesEnvelope( payload: Record, ): Record | null { @@ -1367,9 +1390,11 @@ export function normalizeUpstreamFinalResponse( content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText), reasoningContent: responsesReasoning.reasoningContent, ...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}), - finishReason: toolCalls.length > 0 - ? 'tool_calls' - : (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'), + finishReason: responsesStatusToChatFinishReason( + payload.status ?? payload.finish_reason, + payload.incomplete_details, + toolCalls.length > 0, + ), toolCalls, }; } @@ -1541,13 +1566,24 @@ export function normalizeUpstreamStreamEvent( return { role: 'assistant' }; } - if (type === 'response.failed' || type === 'response.incomplete' || type === 'error') { + if (type === 'response.incomplete' || type === 'response.failed' || 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'; + let finishReason: string; + if (type === 'response.incomplete') { + finishReason = responsesStatusToChatFinishReason( + 'incomplete', + (responsePayload as any)?.incomplete_details ?? (payload as any).incomplete_details, + false, + ); + } else if (type === 'response.failed') { + finishReason = responsesStatusToChatFinishReason('failed', null, false); + } else { + finishReason = normalizeStopReason( + (responsePayload as any)?.status + ?? (payload as any).status + ?? (payload as any).type, + ) || 'error'; + } return { finishReason, done: true, diff --git a/src/server/transformers/shared/endpointCompatibility.test.ts b/src/server/transformers/shared/endpointCompatibility.test.ts new file mode 100644 index 0000000..24c7eb0 --- /dev/null +++ b/src/server/transformers/shared/endpointCompatibility.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { + hasEndpointMismatchHint, + inferRequiredEndpointFromProtocolError, + inferSuggestedEndpointFromUpstreamError, + promoteRequiredEndpointCandidateAfterProtocolError, +} from './endpointCompatibility.js'; + +describe('inferRequiredEndpointFromProtocolError', () => { + it('recognizes messages-required protocol errors', () => { + expect(inferRequiredEndpointFromProtocolError('messages is required')).toBe('messages'); + expect(inferRequiredEndpointFromProtocolError('{"error":{"message":"messages is required"}}')).toBe('messages'); + }); + + it('recognizes responses-input-required protocol errors', () => { + expect(inferRequiredEndpointFromProtocolError('input is required')).toBe('responses'); + expect(inferRequiredEndpointFromProtocolError('{"error":{"message":"input is required"}}')).toBe('responses'); + }); + + it('ignores unrelated protocol errors', () => { + expect(inferRequiredEndpointFromProtocolError('unsupported endpoint')).toBeNull(); + expect(inferRequiredEndpointFromProtocolError('')).toBeNull(); + expect(inferRequiredEndpointFromProtocolError(null)).toBeNull(); + }); +}); + +describe('inferSuggestedEndpointFromUpstreamError', () => { + it('prefers required endpoints over generic path mentions', () => { + expect(inferSuggestedEndpointFromUpstreamError('input is required for /v1/chat/completions')).toBe('responses'); + }); + + it('infers suggested endpoints from explicit upstream endpoint mentions', () => { + expect(inferSuggestedEndpointFromUpstreamError('Unsupported endpoint /v1/messages')).toBe('messages'); + expect(inferSuggestedEndpointFromUpstreamError('POST /v1/responses is not supported')).toBe('responses'); + }); +}); + +describe('hasEndpointMismatchHint', () => { + it('recognizes endpoint mismatch vocabulary from raw or parsed errors', () => { + expect(hasEndpointMismatchHint('Unsupported endpoint /v1/messages')).toBe(true); + expect(hasEndpointMismatchHint('{"error":{"message":"Unknown endpoint /v1/responses"}}')).toBe(true); + }); + + it('ignores generic upstream errors without endpoint hints', () => { + expect(hasEndpointMismatchHint('{"error":{"type":"upstream_error","message":"Upstream request failed"}}')).toBe(false); + }); +}); + +describe('promoteRequiredEndpointCandidateAfterProtocolError', () => { + it('promotes the required endpoint to the next slot when it appears later in the order', () => { + const candidates: Array<'chat' | 'messages' | 'responses'> = ['chat', 'messages', 'responses']; + + promoteRequiredEndpointCandidateAfterProtocolError(candidates, { + currentEndpoint: 'chat', + upstreamErrorText: 'input is required', + }); + + expect(candidates).toEqual(['chat', 'responses', 'messages']); + }); + + it('does nothing when the required endpoint is already next or missing', () => { + const alreadyNext: Array<'chat' | 'messages' | 'responses'> = ['chat', 'messages', 'responses']; + promoteRequiredEndpointCandidateAfterProtocolError(alreadyNext, { + currentEndpoint: 'chat', + upstreamErrorText: 'messages is required', + }); + expect(alreadyNext).toEqual(['chat', 'messages', 'responses']); + + const missingTarget: Array<'chat' | 'messages' | 'responses'> = ['chat', 'messages']; + promoteRequiredEndpointCandidateAfterProtocolError(missingTarget, { + currentEndpoint: 'chat', + upstreamErrorText: 'input is required', + }); + expect(missingTarget).toEqual(['chat', 'messages']); + }); +}); diff --git a/src/server/transformers/shared/endpointCompatibility.ts b/src/server/transformers/shared/endpointCompatibility.ts index 0540cb0..63f4d2c 100644 --- a/src/server/transformers/shared/endpointCompatibility.ts +++ b/src/server/transformers/shared/endpointCompatibility.ts @@ -3,6 +3,13 @@ import type { DownstreamFormat } from './normalized.js'; export type CompatibilityEndpoint = 'chat' | 'messages' | 'responses'; export type CompatibilityEndpointPreference = DownstreamFormat | 'responses'; +type ParsedEndpointErrorShape = { + code: string; + message: string; + text: string; + type: string; +}; + type PreferResponsesAfterLegacyChatErrorInput = { status: number; upstreamErrorText?: string | null; @@ -56,6 +63,46 @@ function normalizeHeaderMap(headers: Record): Record; + const error = (parsed.error && typeof parsed.error === 'object') + ? parsed.error as Record + : parsed; + return { + code: asTrimmedString(error.code).toLowerCase(), + message: asTrimmedString(error.message).toLowerCase(), + text, + type: asTrimmedString(error.type).toLowerCase(), + }; + } catch { + return { + code: '', + message: '', + text, + type: '', + }; + } +} + +function inferEndpointMentionFromText(text: string): CompatibilityEndpoint | null { + if (!text) return null; + if (text.includes('/v1/responses') || /\bresponses\b/.test(text)) return 'responses'; + if (text.includes('/v1/messages') || /\bmessages\b/.test(text)) return 'messages'; + if (text.includes('/v1/chat/completions') || /\bchat(?:\/completions)?\b/.test(text)) return 'chat'; + return null; +} + export function buildMinimalJsonHeadersForCompatibility(input: { headers: Record; endpoint: CompatibilityEndpoint; @@ -108,6 +155,68 @@ export function isEndpointDispatchDeniedError(status: number, upstreamErrorText? ); } +export function inferRequiredEndpointFromProtocolError( + upstreamErrorText?: string | null, +): CompatibilityEndpoint | null { + const parsed = parseEndpointErrorShape(upstreamErrorText); + const combined = `${parsed.text}\n${parsed.message}`; + if (!combined.trim()) return null; + if (/messages\s+is\s+required/i.test(combined)) return 'messages'; + if (/input\s+is\s+required/i.test(combined)) return 'responses'; + return null; +} + +export function inferSuggestedEndpointFromUpstreamError( + upstreamErrorText?: string | null, +): CompatibilityEndpoint | null { + const requiredEndpoint = inferRequiredEndpointFromProtocolError(upstreamErrorText); + if (requiredEndpoint) return requiredEndpoint; + + const parsed = parseEndpointErrorShape(upstreamErrorText); + return ( + inferEndpointMentionFromText(parsed.message) + || inferEndpointMentionFromText(parsed.text) + ); +} + +export function hasEndpointMismatchHint(upstreamErrorText?: string | null): boolean { + const parsed = parseEndpointErrorShape(upstreamErrorText); + if (!parsed.text) return false; + + const phrases = [ + 'not found', + 'unknown endpoint', + 'unsupported endpoint', + 'unsupported path', + 'unrecognized request url', + 'no route matched', + 'does not exist', + 'invalid url', + ]; + return phrases.some((phrase) => ( + parsed.text.includes(phrase) || parsed.message.includes(phrase) + )) || inferSuggestedEndpointFromUpstreamError(upstreamErrorText) !== null; +} + +export function promoteRequiredEndpointCandidateAfterProtocolError( + endpointCandidates: CompatibilityEndpoint[], + input: { + currentEndpoint?: CompatibilityEndpoint | null; + upstreamErrorText?: string | null; + }, +): void { + const currentEndpoint = input.currentEndpoint ?? null; + const requiredEndpoint = inferRequiredEndpointFromProtocolError(input.upstreamErrorText); + if (!currentEndpoint || !requiredEndpoint || currentEndpoint === requiredEndpoint) return; + + const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === currentEndpoint); + const requiredIndex = endpointCandidates.indexOf(requiredEndpoint); + if (currentIndex < 0 || requiredIndex < 0 || requiredIndex <= currentIndex + 1) return; + + endpointCandidates.splice(requiredIndex, 1); + endpointCandidates.splice(currentIndex + 1, 0, requiredEndpoint); +} + export function shouldPreferResponsesAfterLegacyChatError( input: PreferResponsesAfterLegacyChatErrorInput, ): boolean { @@ -150,26 +259,11 @@ export function promoteResponsesCandidateAfterLegacyChatError( export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean { if (status < 400) return false; - const text = (upstreamErrorText || '').toLowerCase(); + const parsed = parseEndpointErrorShape(upstreamErrorText); + const text = parsed.text; if (status === 404 || status === 405 || status === 415 || status === 501) return true; if (!text) return false; - - let parsedCode = ''; - let parsedType = ''; - let parsedMessage = ''; - try { - const parsed = JSON.parse(upstreamErrorText || '{}') as Record; - const error = (parsed.error && typeof parsed.error === 'object') - ? parsed.error as Record - : parsed; - parsedCode = asTrimmedString(error.code).toLowerCase(); - parsedType = asTrimmedString(error.type).toLowerCase(); - parsedMessage = asTrimmedString(error.message).toLowerCase(); - } catch { - parsedCode = ''; - parsedType = ''; - parsedMessage = ''; - } + const endpointMismatchHint = hasEndpointMismatchHint(upstreamErrorText); return ( isEndpointDispatchDeniedError(status, upstreamErrorText) @@ -181,8 +275,14 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str || text.includes('unrecognized request url') || text.includes('no route matched') || text.includes('does not exist') - || text.includes('openai_error') - || text.includes('upstream_error') + || ( + text.includes('openai_error') + && endpointMismatchHint + ) + || ( + text.includes('upstream_error') + && endpointMismatchHint + ) || text.includes('bad_response_status_code') || text.includes('unsupported media type') || text.includes("only 'application/json' is allowed") @@ -191,38 +291,56 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str || text.includes('not implemented') || text.includes('api not implemented') || text.includes('unsupported legacy protocol') - || parsedCode === 'convert_request_failed' - || parsedCode === 'not_found' - || parsedCode === 'endpoint_not_found' - || parsedCode === 'unknown_endpoint' - || parsedCode === 'unsupported_endpoint' - || parsedCode === 'bad_response_status_code' - || parsedCode === 'openai_error' - || parsedCode === 'upstream_error' - || parsedType === 'not_found_error' - || parsedType === 'invalid_request_error' - || parsedType === 'unsupported_endpoint' - || parsedType === 'unsupported_path' - || parsedType === 'bad_response_status_code' - || parsedType === 'openai_error' - || parsedType === 'upstream_error' - || parsedMessage.includes('unknown endpoint') - || parsedMessage.includes('unsupported endpoint') - || parsedMessage.includes('unsupported path') - || parsedMessage.includes('unrecognized request url') - || parsedMessage.includes('no route matched') - || parsedMessage.includes('does not exist') - || parsedMessage.includes('bad_response_status_code') - || parsedMessage === 'openai_error' - || parsedMessage === 'upstream_error' - || parsedMessage.includes('unsupported media type') - || parsedMessage.includes("only 'application/json' is allowed") - || parsedMessage.includes('only "application/json" is allowed') + || parsed.code === 'convert_request_failed' + || parsed.code === 'not_found' + || parsed.code === 'endpoint_not_found' + || parsed.code === 'unknown_endpoint' + || parsed.code === 'unsupported_endpoint' + || parsed.code === 'bad_response_status_code' + || ( + parsed.code === 'openai_error' + && endpointMismatchHint + ) + || ( + parsed.code === 'upstream_error' + && endpointMismatchHint + ) + || parsed.type === 'not_found_error' + || parsed.type === 'invalid_request_error' + || parsed.type === 'unsupported_endpoint' + || parsed.type === 'unsupported_path' + || parsed.type === 'bad_response_status_code' + || ( + parsed.type === 'openai_error' + && endpointMismatchHint + ) + || ( + parsed.type === 'upstream_error' + && endpointMismatchHint + ) + || parsed.message.includes('unknown endpoint') + || parsed.message.includes('unsupported endpoint') + || parsed.message.includes('unsupported path') + || parsed.message.includes('unrecognized request url') + || parsed.message.includes('no route matched') + || parsed.message.includes('does not exist') + || parsed.message.includes('bad_response_status_code') + || ( + parsed.message === 'openai_error' + && endpointMismatchHint + ) + || ( + parsed.message === 'upstream_error' + && endpointMismatchHint + ) + || parsed.message.includes('unsupported media type') + || parsed.message.includes("only 'application/json' is allowed") + || parsed.message.includes('only "application/json" is allowed') || ( status === 400 - && parsedCode === 'invalid_request' - && parsedType === 'new_api_error' - && (parsedMessage.includes('claude code cli') || text.includes('claude code cli')) + && parsed.code === 'invalid_request' + && parsed.type === 'new_api_error' + && (parsed.message.includes('claude code cli') || text.includes('claude code cli')) ) ); } diff --git a/src/web/api.ts b/src/web/api.ts index 473e6b0..e362b5e 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -342,6 +342,7 @@ export type RuntimeRoutingWeightsPayload = { export type RuntimeSettingsPayload = { proxyToken?: string; systemProxyUrl?: string; + payloadRules?: Record | null; modelAvailabilityProbeEnabled?: boolean; codexUpstreamWebsocketEnabled?: boolean; responsesCompactFallbackToResponsesEnabled?: boolean; diff --git a/src/web/index.css b/src/web/index.css index 84f1093..0b584cd 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -6764,14 +6764,13 @@ textarea { border-top-left-radius: 16px; border-top-right-radius: 16px; contain: layout paint; + will-change: transform, opacity, clip-path; + backface-visibility: hidden; + transform-origin: top center; } .route-detail-panel-presence { overflow: clip; - contain: layout paint; - opacity: 1; - transform: none; - transition: grid-template-rows 0.18s cubic-bezier(0.22, 1, 0.36, 1); } .route-detail-panel-presence.is-closing { @@ -6779,18 +6778,44 @@ textarea { } .route-detail-panel-presence.is-open .route-card-detail-panel { - animation: route-detail-panel-in 0.18s cubic-bezier(0.22, 1, 0.36, 1); + animation: route-detail-panel-in 0.26s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.route-detail-panel-presence.is-closing .route-card-detail-panel { + animation: route-detail-panel-out 0.2s cubic-bezier(0.4, 0, 1, 1) both; } @keyframes route-detail-panel-in { from { opacity: 0; - transform: translateY(-4px); + clip-path: inset(0 0 100% 0 round 16px); + transform: translateY(-8px) scale(0.992); + } + + 62% { + opacity: 1; + clip-path: inset(0 0 0 0 round 16px); + transform: translateY(0) scale(1.006); } to { opacity: 1; - transform: translateY(0); + clip-path: inset(0 0 0 0 round 16px); + transform: translateY(0) scale(1); + } +} + +@keyframes route-detail-panel-out { + from { + opacity: 1; + clip-path: inset(0 0 0 0 round 16px); + transform: translateY(0) scale(1); + } + + to { + opacity: 0; + clip-path: inset(0 0 100% 0 round 16px); + transform: translateY(-6px) scale(0.994); } } diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 1c889d7..59ee6c1 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -8,6 +8,16 @@ import ModernSelect from '../components/ModernSelect.js'; import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js'; import FactoryResetModal from './settings/FactoryResetModal.js'; import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js'; +import { + createCodexDefaultHighReasoningVisualPreset, + createVisualPayloadRule, + isVisualPayloadRuleBlank, + payloadRulesToVisualRules, + type PayloadRuleAction, + type VisualPayloadRule, + type VisualPayloadRuleValueMode, + visualRulesToPayloadRules, +} from './settings/payloadRulesVisual.js'; import UpdateCenterSection from './settings/UpdateCenterSection.js'; import { applyRoutingProfilePreset, @@ -44,6 +54,8 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => { type DbDialect = 'sqlite' | 'mysql' | 'postgres'; type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value']; type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning'; +type PayloadRulesEditorSectionKey = PayloadRuleAction; +type PayloadRulesEditorDrafts = Record; type RuntimeSettings = { checkinCron: string; @@ -118,6 +130,154 @@ type ShorthandConnection = { database: string; }; +const PAYLOAD_RULES_EDITOR_SECTIONS = [ + { + key: 'default', + title: 'default', + description: '字段缺失时才注入,适合补默认参数。', + placeholder: `[ + { + "models": [{ "name": "gpt-*", "protocol": "codex" }], + "params": { + "reasoning.effort": "high" + } + } +]`, + }, + { + key: 'default-raw', + title: 'default-raw', + description: '字段缺失时注入原始 JSON,适合 schema、复杂对象等值。', + placeholder: `[ + { + "models": [{ "name": "gpt-*", "protocol": "codex" }], + "params": { + "response_format": "{\"type\":\"json_schema\"}" + } + } +]`, + }, + { + key: 'override', + title: 'override', + description: '无论原请求是否已有该字段,都强制覆盖。', + placeholder: `[ + { + "models": [{ "name": "gpt-*", "protocol": "codex" }], + "params": { + "text.verbosity": "low" + } + } +]`, + }, + { + key: 'override-raw', + title: 'override-raw', + description: '无论原请求是否已有该字段,都强制覆盖为原始 JSON。', + placeholder: `[ + { + "models": [{ "name": "gemini-*", "protocol": "gemini" }], + "params": { + "generationConfig.responseJsonSchema": "{\"type\":\"object\"}" + } + } +]`, + }, + { + key: 'filter', + title: 'filter', + description: '删除匹配请求中的字段。', + placeholder: `[ + { + "models": [{ "name": "gpt-*", "protocol": "codex" }], + "params": ["safety_identifier"] + } +]`, + }, +] as const satisfies ReadonlyArray<{ + key: PayloadRulesEditorSectionKey; + title: string; + description: string; + placeholder: string; +}>; + +const PAYLOAD_RULE_ACTION_OPTIONS: Array<{ value: PayloadRuleAction; label: string }> = [ + { value: 'default', label: '默认注入' }, + { value: 'default-raw', label: '默认注入 JSON' }, + { value: 'override', label: '强制覆盖' }, + { value: 'override-raw', label: '强制覆盖 JSON' }, + { value: 'filter', label: '删除字段' }, +]; + +const PAYLOAD_RULE_PROTOCOL_OPTIONS = [ + { value: '', label: '全部协议' }, + { value: 'codex', label: 'Codex' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'claude', label: 'Claude' }, + { value: 'gemini', label: 'Gemini' }, + { value: 'antigravity', label: 'Antigravity' }, +]; + +const PAYLOAD_RULE_VALUE_MODE_OPTIONS: Array<{ value: VisualPayloadRuleValueMode; label: string }> = [ + { value: 'text', label: '文本' }, + { value: 'json', label: 'JSON' }, +]; + +function createEmptyPayloadRuleDrafts(): PayloadRulesEditorDrafts { + return { + default: '', + 'default-raw': '', + override: '', + 'override-raw': '', + filter: '', + }; +} + +function formatPayloadRuleSectionForEditor(value: unknown): string { + if (value == null) return ''; + if (Array.isArray(value) && value.length <= 0) return ''; + return JSON.stringify(value, null, 2); +} + +function normalizePayloadRulesForEditor(value: unknown): PayloadRulesEditorDrafts { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return createEmptyPayloadRuleDrafts(); + } + + const record = value as Record; + return { + default: formatPayloadRuleSectionForEditor(record.default), + 'default-raw': formatPayloadRuleSectionForEditor(record.defaultRaw ?? record['default-raw']), + override: formatPayloadRuleSectionForEditor(record.override), + 'override-raw': formatPayloadRuleSectionForEditor(record.overrideRaw ?? record['override-raw']), + filter: formatPayloadRuleSectionForEditor(record.filter), + }; +} + +function parsePayloadRulesFromDrafts( + drafts: PayloadRulesEditorDrafts, +): { success: true; value: Record } | { success: false; message: string } { + const next: Record = {}; + + for (const section of PAYLOAD_RULES_EDITOR_SECTIONS) { + const raw = drafts[section.key].trim(); + if (!raw) continue; + try { + next[section.key] = JSON.parse(raw); + } catch (error: any) { + return { + success: false, + message: `Payload 规则 ${section.title} 不是合法 JSON:${error?.message || '解析失败'}`, + }; + } + } + + return { + success: true, + value: next, + }; +} + const defaultWeights: RoutingWeights = { baseWeightFactor: 0.5, valueScoreFactor: 0.5, @@ -225,6 +385,11 @@ export default function Settings() { const [testingSystemProxy, setTestingSystemProxy] = useState(false); const [systemProxyTestState, setSystemProxyTestState] = useState(null); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); + const [payloadVisualRules, setPayloadVisualRules] = useState([]); + const [payloadRuleDrafts, setPayloadRuleDrafts] = useState(createEmptyPayloadRuleDrafts()); + const [payloadAdvancedDirty, setPayloadAdvancedDirty] = useState(false); + const [savingPayloadRules, setSavingPayloadRules] = useState(false); + const [showPayloadRulesEditor, setShowPayloadRulesEditor] = useState(false); const [savingRouting, setSavingRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); const [allBrandNames, setAllBrandNames] = useState(null); @@ -272,6 +437,11 @@ export default function Settings() { [runtime.routingWeights], ); + const configuredPayloadRuleCount = useMemo( + () => payloadVisualRules.filter((rule) => !isVisualPayloadRuleBlank(rule)).length, + [payloadVisualRules], + ); + const generatedConnectionString = useMemo(() => ( buildShorthandConnectionString(migrationDialect, shorthandConnection) ), [migrationDialect, shorthandConnection]); @@ -465,6 +635,30 @@ export default function Settings() { ? '已启用' : '已关闭'; + const syncPayloadRuleDraftsFromObject = (value: unknown) => { + setPayloadRuleDrafts(normalizePayloadRulesForEditor(value)); + setPayloadAdvancedDirty(false); + }; + + const syncPayloadVisualRulesFromObject = (value: unknown) => { + setPayloadVisualRules(payloadRulesToVisualRules(value)); + }; + + const applyVisualPayloadRules = ( + nextRulesOrUpdater: VisualPayloadRule[] | ((current: VisualPayloadRule[]) => VisualPayloadRule[]), + ) => { + setPayloadVisualRules((currentRules) => { + const nextRules = typeof nextRulesOrUpdater === 'function' + ? nextRulesOrUpdater(currentRules) + : nextRulesOrUpdater; + const serialized = visualRulesToPayloadRules(nextRules); + if (serialized.success) { + syncPayloadRuleDraftsFromObject(serialized.value); + } + return nextRules; + }); + }; + const loadSettings = async () => { setLoading(true); try { @@ -531,6 +725,8 @@ export default function Settings() { ? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n') : '', ); + syncPayloadRuleDraftsFromObject(runtimeInfo.payloadRules); + syncPayloadVisualRulesFromObject(runtimeInfo.payloadRules); setAdminIpAllowlistText( Array.isArray(runtimeInfo.adminIpAllowlist) ? runtimeInfo.adminIpAllowlist.join('\n') @@ -783,6 +979,80 @@ export default function Settings() { } }; + const savePayloadRules = async () => { + const nextPayloadRules = payloadAdvancedDirty + ? parsePayloadRulesFromDrafts(payloadRuleDrafts) + : visualRulesToPayloadRules(payloadVisualRules); + if (!nextPayloadRules.success) { + toast.error(nextPayloadRules.message); + return; + } + + setSavingPayloadRules(true); + try { + const res = await api.updateRuntimeSettings({ + payloadRules: nextPayloadRules.value, + }); + syncPayloadRuleDraftsFromObject(res?.payloadRules); + syncPayloadVisualRulesFromObject(res?.payloadRules); + toast.success('Payload 规则已保存'); + } catch (err: any) { + toast.error(err?.message || '保存 Payload 规则失败'); + } finally { + setSavingPayloadRules(false); + } + }; + + const applyCodexDefaultHighReasoningPreset = () => { + applyVisualPayloadRules((currentRules) => [ + ...currentRules.filter((rule) => !isVisualPayloadRuleBlank(rule)), + ...createCodexDefaultHighReasoningVisualPreset(), + ]); + setShowPayloadRulesEditor(true); + toast.success('已填入 Codex 默认高推理预设'); + }; + + const addPayloadVisualRule = () => { + applyVisualPayloadRules((currentRules) => [ + ...currentRules, + createVisualPayloadRule(), + ]); + }; + + const updatePayloadVisualRule = (ruleId: string, patch: Partial) => { + applyVisualPayloadRules((currentRules) => currentRules.map((rule) => { + if (rule.id !== ruleId) return rule; + const nextAction = (patch.action ?? rule.action) as PayloadRuleAction; + const nextValueMode = patch.valueMode ?? ( + nextAction === 'default-raw' || nextAction === 'override-raw' + ? 'json' + : rule.valueMode + ); + return { + ...rule, + ...patch, + action: nextAction, + valueMode: nextAction === 'filter' ? 'text' : nextValueMode, + value: nextAction === 'filter' ? '' : (patch.value ?? rule.value), + }; + })); + }; + + const removePayloadVisualRule = (ruleId: string) => { + applyVisualPayloadRules((currentRules) => currentRules.filter((rule) => rule.id !== ruleId)); + }; + + const syncVisualRulesFromAdvancedJson = () => { + const parsedPayloadRules = parsePayloadRulesFromDrafts(payloadRuleDrafts); + if (!parsedPayloadRules.success) { + toast.error(parsedPayloadRules.message); + return; + } + syncPayloadVisualRulesFromObject(parsedPayloadRules.value); + setPayloadAdvancedDirty(false); + toast.success('已将高级 JSON 同步到可视化规则'); + }; + const saveRouting = async () => { setSavingRouting(true); try { @@ -1264,6 +1534,256 @@ export default function Settings() { +
+
+
+
Payload 规则
+
+ 对匹配模型的上游请求做默认注入、强制覆盖或字段过滤。规则结构参考 CPA 的 payload 配置,常见场景可直接注入 + {' '} + reasoning.effort + {' '} + 之类的参数。 +
+
+
+ 0 ? 'primary' : 'neutral')}> + {configuredPayloadRuleCount > 0 ? `已配置 ${configuredPayloadRuleCount} 条` : '未配置'} + + + {payloadAdvancedDirty ? '高级 JSON 待同步/保存' : '保存后立即生效'} + +
+
+
+
+
+
常用预设
+
+ 先用预设快速填充,再通过下面的可视化规则编辑器细调。复杂场景仍可回退到高级 JSON。 +
+
+
+ + + +
+
+
+ {payloadVisualRules.length <= 0 ? ( +
+
还没有可视化规则
+
+ 可以先点上面的预设,也可以直接新增一条规则:选择动作、协议、模型匹配、字段路径和值即可。 +
+
+ ) : ( +
+ {payloadVisualRules.map((rule, index) => ( +
+
+
规则 {index + 1}
+ +
+ +
+
动作
+ updatePayloadVisualRule(rule.id, { action: value as PayloadRuleAction })} + options={PAYLOAD_RULE_ACTION_OPTIONS} + placeholder="选择动作" + /> +
+
+
协议
+ updatePayloadVisualRule(rule.id, { protocol: String(value || '') })} + options={PAYLOAD_RULE_PROTOCOL_OPTIONS} + placeholder="全部协议" + /> +
+
+
模型匹配
+ updatePayloadVisualRule(rule.id, { modelPattern: e.target.value })} + placeholder="例如 gpt-*" + style={inputStyle} + /> +
+
+
字段路径
+ updatePayloadVisualRule(rule.id, { path: e.target.value })} + placeholder="例如 reasoning.effort" + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + /> +
+
+ {rule.action === 'filter' ? ( +
+ 删除字段规则不需要填写值,命中后会从请求中移除这条路径。 +
+ ) : ( +
+ {(rule.action === 'default' || rule.action === 'override') && ( +
+
值类型
+ updatePayloadVisualRule(rule.id, { + valueMode: value as VisualPayloadRuleValueMode, + value: value === 'json' && rule.valueMode !== 'json' + ? (rule.value ? JSON.stringify(rule.value) : '') + : rule.value, + })} + options={PAYLOAD_RULE_VALUE_MODE_OPTIONS} + placeholder="值类型" + /> +
+ )} +
+
+ {rule.action === 'default-raw' || rule.action === 'override-raw' + ? '原始 JSON 值' + : (rule.valueMode === 'json' ? 'JSON 值' : '文本值')} +
+ {(rule.action === 'default-raw' || rule.action === 'override-raw' || rule.valueMode === 'json') ? ( +