diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9064c4f..fecfd5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,7 +319,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 # Keep PR audit informational so build/test/schema remain the merge gate. - continue-on-error: true steps: - name: Checkout @@ -335,7 +334,15 @@ jobs: run: npm ci --prefer-offline --no-audit --no-fund - name: Audit production dependencies - run: npm audit --omit=dev --audit-level=high + run: | + set +e + npm audit --omit=dev --audit-level=high + status=$? + if [ "$status" -ne 0 ] && [ "${{ github.event_name }}" = "pull_request" ]; then + echo "::warning::npm audit reported production dependency vulnerabilities; PR audit stays informational." + exit 0 + fi + exit "$status" publish-docker-arch: name: Publish Docker Image (${{ matrix.arch }}) 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/docs/.vitepress/config.test.ts b/docs/.vitepress/config.test.ts index 0e60588..b70ed40 100644 --- a/docs/.vitepress/config.test.ts +++ b/docs/.vitepress/config.test.ts @@ -1,5 +1,5 @@ import { existsSync, globSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import config from './config'; @@ -36,11 +36,21 @@ const getAlias = (aliasConfig: unknown, specifier: string): string | undefined = const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); const getExpectedEntry = (hoistedRelativePath: string, pnpmPattern: string) => { - const hoistedEntry = resolve(repoRoot, hoistedRelativePath); - if (existsSync(hoistedEntry)) return hoistedEntry; + let currentRoot = repoRoot; - const [pnpmEntry] = globSync(resolve(repoRoot, pnpmPattern)); - return pnpmEntry; + while (true) { + const hoistedEntry = resolve(currentRoot, hoistedRelativePath); + if (existsSync(hoistedEntry)) return hoistedEntry; + + const [pnpmEntry] = globSync(resolve(currentRoot, pnpmPattern)); + if (pnpmEntry) return pnpmEntry; + + const parentRoot = dirname(currentRoot); + if (parentRoot === currentRoot) break; + currentRoot = parentRoot; + } + + return undefined; }; describe('docs vitepress config', () => { diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9a49d17..ed77a67 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,17 +1,28 @@ import { existsSync, globSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); -const resolveDependencyEntry = (hoistedRelativePath: string, pnpmPattern: string) => { - const hoistedEntry = resolve(repoRoot, hoistedRelativePath); - if (existsSync(hoistedEntry)) return hoistedEntry; - const [pnpmEntry] = globSync(resolve(repoRoot, pnpmPattern)); - return pnpmEntry; -}; +function resolveDependencyEntry(hoistedRelativePath: string, pnpmPattern: string) { + let currentRoot = repoRoot; + + while (true) { + const hoistedEntry = resolve(currentRoot, hoistedRelativePath); + if (existsSync(hoistedEntry)) return hoistedEntry; + + const [pnpmEntry] = globSync(resolve(currentRoot, pnpmPattern)); + if (pnpmEntry) return pnpmEntry; + + const parentRoot = dirname(currentRoot); + if (parentRoot === currentRoot) break; + currentRoot = parentRoot; + } + + return undefined; +} const dayjsEsmEntry = resolveDependencyEntry( 'node_modules/dayjs/esm/index.js', diff --git a/docs/k3s-update-center.md b/docs/k3s-update-center.md index 35177d4..17bc9be 100644 --- a/docs/k3s-update-center.md +++ b/docs/k3s-update-center.md @@ -308,6 +308,11 @@ helper 端使用: - `GitHub Releases` - `Docker Hub` +补充一点: + +- `Docker Hub` 里的**自动发现**仍然优先 `latest` / `main` / 稳定 SemVer 标签 +- 如果你要部署 `dev`、分支名、临时标签或 `sha-*` 这类非稳定标签,直接在 Docker Hub 卡片底部用“手动部署 Docker Hub 标签”填写即可 + 对已经跑起来的 K3s / Helm 用户来说,更新中心的日常配置主要就在这一页: ```text @@ -325,7 +330,7 @@ helper 端使用: - 版本来源发现了可部署版本 - 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest - Deploy Helper 显示健康 -4. 再点部署按钮 +4. 如果你要跟随稳定候选,直接点部署按钮;如果你要切到 `dev` / 分支 / 临时标签,就在 Docker Hub 卡片底部手动填写 tag,必要时连 digest 一起填 5. 在页面下方看部署日志 6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像 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..42c727a --- /dev/null +++ b/scripts/dev/update-readme-contributors.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + parsePaginatedJson, + 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 { + expect(() => renderContributorsBlock([], 0)).toThrow('perLine must be a positive integer'); + }); + + it('parses concatenated paginated gh api arrays without requiring newlines between pages', () => { + expect(parsePaginatedJson('[{"login":"a"}][{"login":"b"}]')).toEqual([ + { login: 'a' }, + { login: 'b' }, + ]); + }); +}); diff --git a/scripts/dev/update-readme-contributors.ts b/scripts/dev/update-readme-contributors.ts new file mode 100644 index 0000000..2cb97ff --- /dev/null +++ b/scripts/dev/update-readme-contributors.ts @@ -0,0 +1,197 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO = 'cita-777/metapi'; +const README_FILES = ['README.md', 'README_EN.md'] as const; +const START_MARKER = ''; +const END_MARKER = ''; +const PER_LINE = 10; +const EXCLUDED_LOGINS = new Set(['dependabot[bot]']); + +type GitHubContributor = { + login: string | null; + html_url: string | null; + avatar_url: string | null; + contributions?: number; +}; + +export type ReadmeContributor = { + login: string; + htmlUrl: string; + avatarUrl: string; +}; + +export function normalizeContributors( + contributors: GitHubContributor[], +): ReadmeContributor[] { + const seen = new Set(); + + 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 (!Number.isInteger(perLine) || perLine <= 0) { + throw new RangeError('perLine must be a positive integer'); + } + + 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[]); +} + +export function parsePaginatedJson(raw: string): unknown[] { + const trimmedRaw = raw.trim(); + if (!trimmedRaw) return []; + + try { + const parsed = JSON.parse(trimmedRaw); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + // Fall through to paginated-array parsing. + } + + try { + const mergedArrays = JSON.parse(`[${trimmedRaw.replace(/\]\s*\[/g, '],[')}]`); + if (Array.isArray(mergedArrays)) { + return mergedArrays.flatMap((item) => (Array.isArray(item) ? item : [item])); + } + } catch { + // Fall through to line-by-line parsing. + } + + const items: unknown[] = []; + for (const line of trimmedRaw.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..f59e799 --- /dev/null +++ b/src/server/proxy-core/capabilities/responsesCompact.test.ts @@ -0,0 +1,104 @@ +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('forces compact requests back to application/json even when callers asked for SSE', () => { + expect(ensureCompactResponsesJsonAcceptHeader({ + Accept: 'text/event-stream', + }, { + sitePlatform: 'codex', + })).toEqual({ + accept: 'application/json', + }); + }); + + it('preserves unrelated platforms', () => { + 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..c81ea3d 100644 --- a/src/server/proxy-core/capabilities/responsesCompact.ts +++ b/src/server/proxy-core/capabilities/responsesCompact.ts @@ -2,35 +2,82 @@ 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 nextHeaders = { ...headers }; + delete (nextHeaders as Record).Accept; + delete (nextHeaders as Record).accept; + return { + ...nextHeaders, + 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..7e3985e 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'; @@ -102,10 +105,16 @@ 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') { - return String(rawValue || '').trim(); + const normalizedEntries = Object.entries(headers).map(([rawKey, rawValue]) => [ + rawKey.trim().toLowerCase(), + String(rawValue || '').trim(), + ] as const); + for (const preferredKey of ['session_id', 'session-id', 'conversation_id', 'conversation-id']) { + const match = normalizedEntries.find(([normalizedKey, normalizedValue]) => ( + normalizedKey === preferredKey && normalizedValue + )); + if (match) { + return match[1]; } } return ''; @@ -417,7 +426,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 +457,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 +493,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 +527,7 @@ export async function handleOpenAiResponsesSurfaceRequest( responsesOriginalBody, downstreamHeaders: request.headers as Record, providerHeaders: buildProviderHeaders(), + codexExplicitSessionId: codexSessionId || null, }); const upstreamPath = ( isCompactRequest && endpoint === 'responses' @@ -506,13 +536,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 +575,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 +627,44 @@ export async function handleOpenAiResponsesSurfaceRequest( ctx.rawErrText = await readRuntimeResponseText(recoveredResponse).catch(() => 'unknown error'); } } + const compactFallbackEnabled = config.responsesCompactFallbackToResponsesEnabled; 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 +749,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 842210d..796cff4 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/accountTokens.sync.test.ts b/src/server/routes/api/accountTokens.sync.test.ts index fe0d5cb..7613976 100644 --- a/src/server/routes/api/accountTokens.sync.test.ts +++ b/src/server/routes/api/accountTokens.sync.test.ts @@ -66,6 +66,7 @@ describe('account tokens sync routes with site status', () => { dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-tokens-sync-')); vi.resetModules(); process.env.DATA_DIR = dataDir; + vi.resetModules(); await import('../../db/migrate.js'); const dbModule = await import('../../db/index.js'); diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 87031e7..b81fc0f 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -1,7 +1,7 @@ import Fastify, { type FastifyInstance } from 'fastify'; import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -25,7 +25,6 @@ vi.mock('undici', () => ({ type DbModule = typeof import('../../db/index.js'); type RouteRefreshWorkflowModule = typeof import('../../services/routeRefreshWorkflow.js'); -type ConfigModule = typeof import('../../config.js'); function buildJwt(payload: Record) { const encode = (value: unknown) => Buffer.from(JSON.stringify(value)) @@ -65,25 +64,23 @@ describe('oauth routes', { timeout: 15_000 }, () => { let db: DbModule['db']; let schema: DbModule['schema']; let rebuildRoutesOnly: RouteRefreshWorkflowModule['rebuildRoutesOnly']; - let config: ConfigModule['config']; + let config: typeof import('../../config.js').config; let dataDir = ''; - let previousDataDir: string | undefined; beforeAll(async () => { - previousDataDir = process.env.DATA_DIR; dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-')); - vi.resetModules(); process.env.DATA_DIR = dataDir; + vi.resetModules(); await import('../../db/migrate.js'); - const configModule = await import('../../config.js'); const dbModule = await import('../../db/index.js'); const routesModule = await import('./oauth.js'); const routeRefreshWorkflow = await import('../../services/routeRefreshWorkflow.js'); - config = configModule.config; + const configModule = await import('../../config.js'); db = dbModule.db; schema = dbModule.schema; rebuildRoutesOnly = routeRefreshWorkflow.rebuildRoutesOnly; + config = configModule.config; app = Fastify(); await app.register(routesModule.oauthRoutes); @@ -112,12 +109,7 @@ describe('oauth routes', { timeout: 15_000 }, () => { afterAll(async () => { await app.close(); - if (previousDataDir === undefined) { - delete process.env.DATA_DIR; - } else { - process.env.DATA_DIR = previousDataDir; - } - rmSync(dataDir, { recursive: true, force: true }); + delete process.env.DATA_DIR; }); it('lists multi-provider oauth metadata', async () => { diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index 743cd88..db4fb3c 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -321,42 +321,6 @@ describe('settings and auth events', () => { expect(saved).toBeFalsy(); }); - it('does not persist payload rules when a later runtime setting fails validation', async () => { - const response = await app.inject({ - method: 'PUT', - url: '/api/settings/runtime', - payload: { - payloadRules: { - override: [ - { - models: [{ name: 'gpt-*', protocol: 'codex' }], - params: { - 'reasoning.effort': 'high', - }, - }, - ], - }, - modelAvailabilityProbeEnabled: 'invalid-boolean', - }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toMatchObject({ - success: false, - message: '批量测活开关格式无效:需要 boolean', - }); - - const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get(); - expect(saved).toBeFalsy(); - expect(config.payloadRules).toEqual({ - default: [], - defaultRaw: [], - override: [], - overrideRaw: [], - filter: [], - }); - }); - 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/stats.ts b/src/server/routes/api/stats.ts index e2cad77..c6cdf8e 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -31,7 +31,7 @@ import { getProxyDebugTraceDetail, listProxyDebugTraces, } from "../../services/proxyDebugTraceStore.js"; -import { parseProxyLogMessageMeta } from "../proxy/logPathMeta.js"; +import { parseProxyLogMessageMeta } from "../../services/proxyLogMessage.js"; import { requiresManagedAccountTokens } from "../../services/accountExtraConfig.js"; import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from "../../services/accountTokenService.js"; import { diff --git a/src/server/routes/proxy/architecture-boundaries.test.ts b/src/server/routes/proxy/architecture-boundaries.test.ts index a940037..bb1707c 100644 --- a/src/server/routes/proxy/architecture-boundaries.test.ts +++ b/src/server/routes/proxy/architecture-boundaries.test.ts @@ -100,7 +100,7 @@ describe('proxy route architecture boundaries', () => { it('keeps codex responses normalization behind transformer helpers', () => { const source = readSource('./upstreamEndpoint.ts'); - expect(source).toContain("from '../../transformers/openai/responses/codexCompatibility.js'"); + expect(source).toContain("from '../../services/upstreamRequestBuilder.js'"); expect(source).not.toContain('function ensureCodexResponsesInstructions('); expect(source).not.toContain('function ensureCodexResponsesStoreFalse('); expect(source).not.toContain('function stripCodexUnsupportedResponsesFields('); 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 c12776e..65af910 100644 --- a/src/server/routes/proxy/chat.count-tokens.test.ts +++ b/src/server/routes/proxy/chat.count-tokens.test.ts @@ -214,6 +214,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', @@ -277,4 +278,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..49b5a29 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -10,9 +10,9 @@ import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../servi import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; +import { composeProxyLogMessage } from '../../services/proxyLogMessage.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/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 1b076df..3328797 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -10,7 +10,7 @@ import { parseProxyUsage } from '../../services/proxyUsageParser.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; +import { composeProxyLogMessage } from '../../services/proxyLogMessage.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; import { getProxyAuthContext } from '../../middleware/auth.js'; diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index 7b18bc1..ff67434 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,27 @@ 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: [ + 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 +911,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 +954,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 +985,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 +1027,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/images.ts b/src/server/routes/proxy/images.ts index 7b84c5c..6f15486 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -9,7 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; +import { composeProxyLogMessage } from '../../services/proxyLogMessage.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; import { getProxyAuthContext } from '../../middleware/auth.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 ba75433..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,8 +90,8 @@ vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ })); vi.mock('../../services/oauth/quota.js', () => ({ - recordOauthQuotaHeadersSnapshot: async () => undefined, - recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args), + recordOauthQuotaHeadersSnapshot: async (input: unknown) => recordOauthQuotaHeadersSnapshotMock(input), + recordOauthQuotaResetHint: async (input: unknown) => recordOauthQuotaResetHintMock(input), })); vi.mock('../../db/index.js', () => ({ @@ -198,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; @@ -336,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' }, @@ -426,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', @@ -453,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([ @@ -465,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', @@ -572,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 }, @@ -1005,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(); @@ -1149,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..0fbbc0a 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('does not fall back from compact to ordinary responses for generic openai-compatible upstreams when the fallback toggle is off', 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({ + error: { + message: 'Invalid URL (POST /v1/responses/compact)', + type: 'invalid_request_error', + }, + }), { + status: 404, + headers: { 'content-type': 'application/json' }, + })); const response = await app.inject({ method: 'POST', @@ -207,8 +219,7 @@ 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(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact'); }); it('optionally falls back from compact to ordinary responses when compact is explicitly unsupported', async () => { @@ -260,6 +271,119 @@ 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 () => { + config.responsesCompactFallbackToResponsesEnabled = true; + 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 () => { + config.responsesCompactFallbackToResponsesEnabled = true; + 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 +428,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 +484,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 +698,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..64d64d7 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -2,6 +2,26 @@ 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' | null { + const rawUrl = request.raw.url || request.url || ''; + const pathname = rawUrl.split('?')[0] || rawUrl; + if (pathname === '/responses') return '/v1/responses'; + return pathname.endsWith('/compact') + ? '/v1/responses/compact' + : null; +} + +async function replyUnsupportedAliasedResponsesPath(reply: FastifyReply) { + return reply.code(404).send({ + error: { + message: 'Unknown /responses alias path', + type: 'invalid_request_error', + }, + }); +} + export async function responsesProxyRoute(app: FastifyInstance) { ensureResponsesWebsocketTransport(app); @@ -16,4 +36,33 @@ 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) => { + const downstreamPath = resolveAliasedResponsesDownstreamPath(request); + if (!downstreamPath) { + return replyUnsupportedAliasedResponsesPath(reply); + } + return handleOpenAiResponsesSurfaceRequest(request, reply, downstreamPath); + }); + app.get('/responses', async (_request: FastifyRequest, reply: FastifyReply) => + reply.code(426).send({ + error: { + message: 'WebSocket upgrade required for GET /v1/responses', + type: 'invalid_request_error', + }, + })); + app.get('/responses/*', async (request: FastifyRequest, reply: FastifyReply) => { + const downstreamPath = resolveAliasedResponsesDownstreamPath(request); + if (!downstreamPath) { + return replyUnsupportedAliasedResponsesPath(reply); + } + return reply.code(426).send({ + error: { + message: `WebSocket upgrade required for GET ${downstreamPath}`, + 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 dbd11ac..13a36fe 100644 --- a/src/server/routes/proxy/responses.websocket.test.ts +++ b/src/server/routes/proxy/responses.websocket.test.ts @@ -195,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 })); @@ -749,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..ab8ed84 100644 --- a/src/server/routes/proxy/responsesWebsocket.ts +++ b/src/server/routes/proxy/responsesWebsocket.ts @@ -552,8 +552,10 @@ async function handleResponsesWebsocketConnection( request: IncomingMessage, authContext: ResponsesWebsocketAuthContext, ) { - const websocketSessionId = headerValueToTrimmedString(request.headers['session_id']) - || headerValueToTrimmedString(request.headers['session-id']) + 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..0486b86 --- /dev/null +++ b/src/server/routes/proxy/routeAliases.test.ts @@ -0,0 +1,145 @@ +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 /v1/responses', + type: 'invalid_request_error', + }, + }); + }); + + it('rejects unknown /responses alias subpaths instead of silently rewriting them', async () => { + const response = await app.inject({ + method: 'POST', + url: '/responses/other', + payload: { model: 'gpt-5.2', input: 'hello' }, + }); + + expect(response.statusCode).toBe(404); + expect(handleOpenAiResponsesSurfaceRequestMock).not.toHaveBeenCalled(); + expect(response.json()).toEqual({ + error: { + message: 'Unknown /responses alias path', + type: 'invalid_request_error', + }, + }); + }); + + it('keeps GET /responses/compact aligned with the websocket upgrade path', async () => { + const response = await app.inject({ + method: 'GET', + url: '/responses/compact', + }); + + expect(response.statusCode).toBe(426); + expect(handleOpenAiResponsesSurfaceRequestMock).not.toHaveBeenCalled(); + expect(response.json()).toEqual({ + error: { + message: 'WebSocket upgrade required for GET /v1/responses/compact', + 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/search.ts b/src/server/routes/proxy/search.ts index fc4c157..19fd4f7 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -8,7 +8,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; +import { composeProxyLogMessage } from '../../services/proxyLogMessage.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; 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/brandMatcher.test.ts b/src/server/services/brandMatcher.test.ts index a168e1d..7ed86d6 100644 --- a/src/server/services/brandMatcher.test.ts +++ b/src/server/services/brandMatcher.test.ts @@ -23,8 +23,11 @@ describe('brandMatcher', () => { expect(getBlockedBrandRules(['OpenRouter', 'OpenRouter', 'Unknown Brand'])).toEqual(['OpenRouter']); }); - it('canonicalizes blocked brand names before validating them', () => { - expect(getBlockedBrandRules(['openai', ' aws bedrock ', 'unknown brand', 'OPENAI'])).toEqual(['OpenAI', 'AWS Bedrock']); + it('canonicalizes blocked brand names before filtering', () => { + expect(getBlockedBrandRules([' openrouter ', 'OPENROUTER', 'together ai'])).toEqual([ + 'OpenRouter', + 'Together AI', + ]); }); it('uses the shared frontend detection result for global brand blocking', () => { @@ -33,9 +36,6 @@ describe('brandMatcher', () => { 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); - expect(isModelBlockedByBrand('deepinfra/meta-llama/llama-3.3-70b-instruct', getBlockedBrandRules(['DeepInfra']))).toBe(true); - expect(isModelBlockedByBrand('azureai/gpt-4o', getBlockedBrandRules(['Azure AI']))).toBe(true); - expect(isModelBlockedByBrand('bedrock/us.amazon.nova-pro-v1:0', getBlockedBrandRules(['AWS Bedrock']))).toBe(true); const vendorRules = getBlockedBrandRules(['Anthropic', 'Meta', 'Google', 'OpenAI', 'Arcee', 'Xiaomi MiMo']); expect(isModelBlockedByBrand('openrouter/anthropic/claude-3-7-sonnet', vendorRules)).toBe(true); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 3bdcdbe..4cca0d1 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -818,7 +818,7 @@ export async function refreshModelsForAccount( `api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ); if (discoveredApiToken && !isMaskedTokenValue(discoveredApiToken)) { - ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' }); + await ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' }); await db.update(schema.accounts).set({ apiToken: discoveredApiToken, updatedAt: new Date().toISOString(), @@ -846,7 +846,7 @@ export async function refreshModelsForAccount( if (usesManagedTokens && enabledTokens.length === 0) { const fallback = discoveredApiToken || account.apiToken || null; if (fallback) { - ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' }); + await ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' }); enabledTokens = await db.select() .from(schema.accountTokens) .where(and( 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/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/routes/proxy/logPathMeta.ts b/src/server/services/proxyLogMessage.ts similarity index 97% rename from src/server/routes/proxy/logPathMeta.ts rename to src/server/services/proxyLogMessage.ts index f2a2f50..2e7d482 100644 --- a/src/server/routes/proxy/logPathMeta.ts +++ b/src/server/services/proxyLogMessage.ts @@ -2,7 +2,7 @@ import { parseProxyLogMetadata, type ParsedProxyLogMetadata, type ProxyLogUsageSource, -} from '../../../shared/proxyLogMeta.js'; +} from '../../shared/proxyLogMeta.js'; type ComposeProxyLogMessageArgs = { clientKind?: string | null; 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..e37ddb3 --- /dev/null +++ b/src/server/services/upstreamRequestBuilder.test.ts @@ -0,0 +1,177 @@ +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('overrides downstream Accept so responses transport mode wins', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'upstream-gpt', + stream: true, + tokenValue: 'sk-test', + sitePlatform: 'openai', + siteUrl: 'https://example.com', + openaiBody: { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'hello' }], + }, + downstreamFormat: 'openai', + downstreamHeaders: { + accept: 'application/json', + }, + }); + + expect(request.headers.accept).toBe('text/event-stream'); + }); + + 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'); + }); + + it('merges body betas with existing anthropic-beta headers for Claude count_tokens', () => { + const request = buildClaudeCountTokensUpstreamRequest({ + modelName: 'claude-opus-4-6', + tokenValue: 'sk-test', + sitePlatform: 'claude', + claudeBody: { + model: 'claude-opus-4-6', + betas: ['beta-from-body'], + messages: [{ role: 'user', content: 'hello' }], + }, + downstreamHeaders: { + 'anthropic-beta': 'header-beta', + }, + }); + + expect(request.headers['anthropic-beta']).toContain('header-beta'); + expect(request.headers['anthropic-beta']).toContain('beta-from-body'); + }); +}); diff --git a/src/server/services/upstreamRequestBuilder.ts b/src/server/services/upstreamRequestBuilder.ts new file mode 100644 index 0000000..544e8b8 --- /dev/null +++ b/src/server/services/upstreamRequestBuilder.ts @@ -0,0 +1,837 @@ +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 nextHeaders = { ...headers }; + delete (nextHeaders as Record).Accept; + delete (nextHeaders as Record).accept; + + if (input.stream) { + return { + ...nextHeaders, + accept: 'text/event-stream', + }; + } + if (normalizePlatformName(input.sitePlatform) === 'sub2api') { + return { + ...nextHeaders, + 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'] + || '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 mergedBetas = [ + ...asTrimmedString(claudeHeaders['anthropic-beta']) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ...betas, + ]; + const effectiveClaudeHeaders = { + ...claudeHeaders, + ...(mergedBetas.length > 0 + ? { 'anthropic-beta': Array.from(new Set(mergedBetas)).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..cb4bb90 --- /dev/null +++ b/src/server/transformers/anthropic/messages/streamBridge.test.ts @@ -0,0 +1,234 @@ +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 whitespace in anthropic streaming text and thinking deltas', () => { + const streamContext = anthropicMessagesStream.createContext('claude-test'); + const downstreamContext = anthropicMessagesStream.createDownstreamContext(); + + const textResult = consumeAnthropicSseEvent( + { + event: 'content_block_delta', + data: JSON.stringify({ + type: 'content_block_delta', + index: 0, + delta: { + type: 'text_delta', + text: ' padded text ', + }, + }), + }, + streamContext, + downstreamContext, + 'claude-test', + ); + const thinkingResult = consumeAnthropicSseEvent( + { + event: 'content_block_delta', + data: JSON.stringify({ + type: 'content_block_delta', + index: 0, + delta: { + type: 'thinking_delta', + thinking: ' padded thinking ', + }, + }), + }, + streamContext, + downstreamContext, + 'claude-test', + ); + + expect(textResult.lines.join('')).toContain(' padded text '); + expect(thinkingResult.lines.join('')).toContain(' padded thinking '); + }); + + it('preserves partial_json spacing for streaming tool-call deltas', () => { + const streamContext = anthropicMessagesStream.createContext('claude-test'); + const downstreamContext = anthropicMessagesStream.createDownstreamContext(); + + const result = consumeAnthropicSseEvent( + { + event: 'content_block_delta', + data: JSON.stringify({ + type: 'content_block_delta', + index: 0, + delta: { + type: 'input_json_delta', + partial_json: '{ "city": "Paris" }', + }, + }), + }, + streamContext, + downstreamContext, + 'claude-test', + ); + + expect(result.lines.join('')).toContain('partial_json":"{ \\"city\\": \\"Paris\\" }"'); + }); + + 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..eb0bdd5 --- /dev/null +++ b/src/server/transformers/anthropic/messages/streamBridge.ts @@ -0,0 +1,1130 @@ +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 = asUntouchedString(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: asUntouchedString(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 asUntouchedString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +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: asUntouchedString(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: asUntouchedString(delta.thinking ?? delta.text) || undefined, + }; + } + + if (deltaType === 'signature_delta') { + return { + anthropic: { + signatureDelta: asTrimmedString(delta.signature) || undefined, + }, + }; + } + + if (deltaType === 'text_delta') { + return { + contentDelta: asUntouchedString(delta.text) || undefined, + }; + } + + if (deltaType === 'input_json_delta') { + return { + toolCallDeltas: [{ + index, + argumentsDelta: asUntouchedString(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..de3ac8a --- /dev/null +++ b/src/server/transformers/gemini/generate-content/requestBridge.test.ts @@ -0,0 +1,318 @@ +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); + }); + + it('uses canonical tool-call names for Gemini functionResponse parts', () => { + const body = buildCanonicalRequestToGeminiGenerateContentBody({ + operation: 'generate', + surface: 'gemini-generate-content', + cliProfile: 'generic', + requestedModel: 'gemini-2.5-pro', + stream: false, + messages: [ + { + role: 'assistant', + parts: [{ + type: 'tool_call', + id: 'call_weather', + name: 'lookup_weather', + argumentsJson: '{"city":"Paris"}', + }], + }, + { + role: 'tool', + parts: [{ + type: 'tool_result', + toolCallId: 'call_weather', + resultJson: { temperature: '22C' }, + }], + }, + ], + }); + + expect(body.contents).toEqual([ + { + role: 'model', + parts: [{ + functionCall: { + id: 'call_weather', + name: 'lookup_weather', + args: { city: 'Paris' }, + }, + }], + }, + { + role: 'user', + parts: [{ + functionResponse: { + name: 'lookup_weather', + response: { + result: { temperature: '22C' }, + }, + }, + }], + }, + ]); + }); +}); 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 52% rename from src/server/routes/proxy/geminiCliCompat.ts rename to src/server/transformers/gemini/generate-content/requestBridge.ts index 090d60c..d88dcd7 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,225 @@ 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, + toolNameById?: ReadonlyMap, +): 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') { + const response = part.resultJson ?? parseJsonString(part.resultText ?? ''); + return { + functionResponse: { + name: toolNameById?.get(part.toolCallId) || 'unknown', + response: { + result: response, + }, + }, + }; + } + + 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> = []; + const toolNameById = new Map(); + + for (const message of request.messages) { + if (message.role === 'system' || message.role === 'developer') { + systemParts.push( + ...message.parts + .map((part) => canonicalPartToGeminiPart(part, toolNameById)) + .filter((part): part is Record => !!part), + ); + continue; + } + + for (const part of message.parts) { + if (part.type === 'tool_call') { + toolNameById.set(part.id, part.name); + } + } + + const parts = message.parts + .map((part) => canonicalPartToGeminiPart(part, toolNameById)) + .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..2a15176 --- /dev/null +++ b/src/server/transformers/openai/chat/responseBridge.test.ts @@ -0,0 +1,219 @@ +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('builds synthetic chunks for multi-choice finals even when some choices omit toolCalls', () => { + const chunks = buildNormalizedFinalToOpenAiChatChunks({ + id: 'chatcmpl-multi-1', + model: 'gpt-5', + created: 123, + content: '', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + choices: [ + { + index: 0, + role: 'assistant', + content: '', + toolCalls: [{ + id: 'call_multi_1', + name: 'lookup_weather', + arguments: '{"city":"Paris"}', + }], + finishReason: 'tool_calls', + }, + { + index: 1, + role: 'assistant', + content: 'done', + finishReason: 'stop', + }, + ], + } as any); + + expect(chunks[0]).toMatchObject({ + choices: [ + { + index: 0, + delta: { + tool_calls: [{ + id: 'call_multi_1', + type: 'function', + }], + }, + }, + { + index: 1, + delta: { + role: 'assistant', + content: 'done', + }, + }, + ], + }); + expect(chunks[1]).toMatchObject({ + choices: [ + { index: 0, finish_reason: 'tool_calls' }, + { index: 1, finish_reason: 'stop' }, + ], + }); + }); + + 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..e6c6bc9 --- /dev/null +++ b/src/server/transformers/openai/chat/responseBridge.ts @@ -0,0 +1,228 @@ +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 normalizedUsage = isRecord(usage) + && typeof usage.promptTokens === 'number' + && typeof usage.completionTokens === 'number' + && typeof usage.totalTokens === 'number' + ? { + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + } + : { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }; + const payload = serializeFinalResponse( + 'openai', + normalized, + normalizedUsage, + ) 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 toolCalls = Array.isArray(choice.toolCalls) ? choice.toolCalls : []; + const delta: Record = { + role: choice.role || 'assistant', + content: choice.content || '', + }; + if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent; + if (toolCalls.length > 0) { + delta.tool_calls = 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) => { + const toolCalls = Array.isArray(choice.toolCalls) ? choice.toolCalls : []; + return { + index: choice.index, + delta: {}, + finish_reason: choice.finishReason || (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..0ddd823 100644 --- a/src/server/transformers/openai/responses/codexCompatibility.test.ts +++ b/src/server/transformers/openai/responses/codexCompatibility.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { normalizeCodexResponsesBodyForProxy } from './codexCompatibility.js'; +import { + CODEX_DEFAULT_INSTRUCTIONS, + normalizeCodexResponsesBodyForProxy, +} from './codexCompatibility.js'; 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,10 +13,17 @@ 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, + stream_options: { include_usage: true }, temperature: 0.3, store: true, }, 'codex'); @@ -22,16 +32,84 @@ 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('keeps non-text system content in input while lifting only the text blocks into instructions', () => { + const imageBlock = { + type: 'input_image', + image_url: 'https://example.com/reference.png', + }; + + const body = normalizeCodexResponsesBodyForProxy({ + input: [ + { + type: 'message', + role: 'system', + content: [ + { type: 'input_text', text: 'keep the image context' }, + imageBlock, + ], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ], + }, 'codex'); + + expect(body.instructions).toBe('keep the image context'); + expect(body.input).toEqual([ + { + type: 'message', + role: 'system', + content: [imageBlock], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ]); + }); + + it('joins multiple text blocks with separators instead of squashing them together', () => { + const body = normalizeCodexResponsesBodyForProxy({ + input: [ + { + type: 'message', + role: 'system', + content: [ + { type: 'input_text', text: 'first line' }, + { type: 'input_text', text: 'second line' }, + ], + }, + ], + }, 'codex'); + + expect(body.instructions).toBe('first line\nsecond line'); + }); + + 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..67570aa 100644 --- a/src/server/transformers/openai/responses/codexCompatibility.ts +++ b/src/server/transformers/openai/responses/codexCompatibility.ts @@ -6,15 +6,146 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +export const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; + +function extractTextValue(content: Record): string { + 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 isTextOnlyContentRecord(content: Record): boolean { + const blockType = asTrimmedString(content.type).toLowerCase(); + if (blockType === 'input_text' || blockType === 'output_text' || blockType === 'text') { + return true; + } + const keys = Object.keys(content); + return keys.length > 0 && keys.every((key) => ( + key === 'type' + || key === 'text' + || key === 'content' + || key === 'output_text' + || key === 'input_text' + )); +} + +function splitExtractedSystemContent(content: unknown): { + extractedTexts: string[]; + remainingContent?: unknown; +} { + if (typeof content === 'string') { + const trimmed = content.trim(); + return trimmed ? { extractedTexts: [trimmed] } : { extractedTexts: [] }; + } + + if (Array.isArray(content)) { + const extractedTexts: string[] = []; + const remainingItems: unknown[] = []; + + for (const item of content) { + if (typeof item === 'string') { + const trimmed = item.trim(); + if (trimmed) extractedTexts.push(trimmed); + else remainingItems.push(item); + continue; + } + + if (!isRecord(item)) { + remainingItems.push(item); + continue; + } + + const textValue = extractTextValue(item).trim(); + if (textValue && isTextOnlyContentRecord(item)) { + extractedTexts.push(textValue); + continue; + } + + remainingItems.push(item); + } + + return { + extractedTexts, + ...(remainingItems.length > 0 ? { remainingContent: remainingItems } : {}), + }; + } + + if (!isRecord(content)) return { extractedTexts: [] }; + + const textValue = extractTextValue(content).trim(); + if (textValue && isTextOnlyContentRecord(content)) { + return { extractedTexts: [textValue] }; + } + + return { + extractedTexts: [], + remainingContent: content, + }; +} + +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 { extractedTexts, remainingContent } = splitExtractedSystemContent(item.content); + if (extractedTexts.length > 0) { + extractedSystemTexts.push(extractedTexts.join('\n')); + if (remainingContent !== undefined) { + remainingInput.push({ + ...item, + content: remainingContent, + }); + } + continue; + } + + remainingInput.push(item); + } + + 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, }; } @@ -38,38 +169,16 @@ function stripCodexUnsupportedResponsesFields( delete next.max_output_tokens; delete next.max_completion_tokens; delete next.max_tokens; + delete next.stream_options; 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..27b3850 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('preserves explicit chat token limits 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(64); + }); + + 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..9a8c07d 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,22 @@ 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.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.test.ts b/src/server/transformers/openai/responses/outbound.test.ts index 9a8a830..9921ca9 100644 --- a/src/server/transformers/openai/responses/outbound.test.ts +++ b/src/server/transformers/openai/responses/outbound.test.ts @@ -244,7 +244,7 @@ describe('serializeResponsesFinalPayload', () => { expect(payload.output).toEqual([ { - id: 'call_1', + id: 'fc_1', type: 'function_call', status: 'completed', call_id: 'call_1', 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..8c13880 --- /dev/null +++ b/src/server/transformers/openai/responses/responseBridge.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it, vi } 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('generates unique synthetic ids within the same millisecond fallback window', () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1700000000); + try { + const first = buildNormalizedFinalToOpenAiResponsesPayload({ + upstreamPayload: { + id: '', + model: 'gpt-5', + output: [ + { + id: '', + type: 'function_call', + name: 'lookup_weather', + arguments: '{"city":"Paris"}', + }, + { + id: '', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'first' }], + }, + ], + }, + normalized: { + id: '', + model: 'gpt-5', + created: 1700000000, + content: 'first', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [{ + id: '', + name: 'lookup_weather', + arguments: '{"city":"Paris"}', + }], + }, + usage: { + promptTokens: 1, + completionTokens: 1, + totalTokens: 2, + }, + }); + const second = buildNormalizedFinalToOpenAiResponsesPayload({ + upstreamPayload: { + id: '', + model: 'gpt-5', + output: [ + { + id: '', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'second' }], + }, + ], + }, + normalized: { + id: '', + model: 'gpt-5', + created: 1700000000, + content: 'second', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }, + usage: { + promptTokens: 1, + completionTokens: 1, + totalTokens: 2, + }, + }); + + expect(first.id).not.toBe(second.id); + const firstOutput = first.output as Array>; + const secondOutput = second.output as Array>; + expect(firstOutput[1]?.id).not.toBe(secondOutput[0]?.id); + } finally { + nowSpy.mockRestore(); + } + }); + + it('uses fc_* item ids while keeping call_* ids for synthesized function_call outputs', () => { + const payload = buildNormalizedFinalToOpenAiResponsesPayload({ + upstreamPayload: { + id: 'opaque_tool_call_ids', + model: 'gpt-5', + }, + normalized: { + id: 'opaque_tool_call_ids', + model: 'gpt-5', + created: 1700000000, + content: '', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_weather_1', + name: 'lookup_weather', + arguments: '{"city":"Paris"}', + }], + }, + usage: { + promptTokens: 1, + completionTokens: 1, + totalTokens: 2, + }, + }); + + expect(payload.output).toEqual([ + expect.objectContaining({ + id: 'fc_weather_1', + type: 'function_call', + call_id: 'call_weather_1', + }), + ]); + }); + + 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..45a1019 --- /dev/null +++ b/src/server/transformers/openai/responses/responseBridge.ts @@ -0,0 +1,469 @@ +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() : ''; +} + +let syntheticIdCounter = 0; + +function createSyntheticId(prefix: 'resp' | 'msg' | 'call'): string { + syntheticIdCounter += 1; + return `${prefix}_${Date.now()}_${syntheticIdCounter}`; +} + +function toFunctionCallItemId(callId: string): string { + const trimmed = callId.trim(); + if (!trimmed) { + const syntheticCallId = createSyntheticId('call'); + return `fc_${syntheticCallId.slice('call_'.length)}`; + } + return trimmed.startsWith('call_') ? `fc_${trimmed.slice('call_'.length)}` : `fc_${trimmed}`; +} + +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() || createSyntheticId('resp'); + return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`; +} + +function ensureMessageId(rawId: string): string { + const trimmed = rawId.trim() || createSyntheticId('msg'); + return trimmed.startsWith('msg_') ? trimmed : `msg_${trimmed}`; +} + +function ensureFunctionCallId(rawId: string): string { + const trimmed = rawId.trim(); + if (!trimmed) return createSyntheticId('call'); + 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() + : createSyntheticId('resp'); + 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: toFunctionCallItemId(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/server/transformers/shared/normalized.test.ts b/src/server/transformers/shared/normalized.test.ts index 3d3d7df..d7d38fe 100644 --- a/src/server/transformers/shared/normalized.test.ts +++ b/src/server/transformers/shared/normalized.test.ts @@ -154,6 +154,9 @@ describe('shared normalized helpers', () => { id: 'resp_terminal_2', model: 'gpt-test', created: 456, + incomplete_details: { + reason: 'max_output_tokens', + }, output: [ { type: 'message', diff --git a/src/web/index.css b/src/web/index.css index 2af0101..0b584cd 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -6764,6 +6764,7 @@ 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; } @@ -6777,42 +6778,15 @@ textarea { } .route-detail-panel-presence.is-open .route-card-detail-panel { - opacity: 1; - clip-path: inset(0 0 0 0 round 16px); - transform: translateY(0) scale(1); -} - -.route-detail-panel-presence.is-entering .route-card-detail-panel { - will-change: transform, opacity, clip-path; 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 { - will-change: transform, opacity, clip-path; animation: route-detail-panel-out 0.2s cubic-bezier(0.4, 0, 1, 1) both; } -@media (prefers-reduced-motion: reduce) { - .route-detail-panel-presence.is-entering .route-card-detail-panel, - .route-detail-panel-presence.is-closing .route-card-detail-panel { - animation: none; - } - - .route-detail-panel-presence.is-open .route-card-detail-panel { - opacity: 1; - clip-path: inset(0 0 0 0 round 16px); - transform: translateY(0) scale(1); - } - - .route-detail-panel-presence.is-closing .route-card-detail-panel { - opacity: 0; - clip-path: inset(0 0 100% 0 round 16px); - transform: translateY(-6px) scale(0.994); - } -} - @keyframes route-detail-panel-in { - 0% { + from { opacity: 0; clip-path: inset(0 0 100% 0 round 16px); transform: translateY(-8px) scale(0.992); @@ -6824,7 +6798,7 @@ textarea { transform: translateY(0) scale(1.006); } - 100% { + to { opacity: 1; clip-path: inset(0 0 0 0 round 16px); transform: translateY(0) scale(1); @@ -6832,13 +6806,13 @@ textarea { } @keyframes route-detail-panel-out { - 0% { + from { opacity: 1; clip-path: inset(0 0 0 0 round 16px); transform: translateY(0) scale(1); } - 100% { + 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 1043685..691dc9a 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -8,7 +8,6 @@ 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 { PAYLOAD_RULE_PROTOCOL_OPTIONS } from './settings/payloadRuleProtocolOptions.js'; import { createCodexDefaultHighReasoningVisualPreset, createVisualPayloadRule, @@ -19,6 +18,7 @@ import { type VisualPayloadRuleValueMode, visualRulesToPayloadRules, } from './settings/payloadRulesVisual.js'; +import { PAYLOAD_RULE_PROTOCOL_OPTIONS } from './settings/payloadRuleProtocolOptions.js'; import UpdateCenterSection from './settings/UpdateCenterSection.js'; import { applyRoutingProfilePreset, @@ -639,14 +639,6 @@ export default function Settings() { const applyVisualPayloadRules = ( nextRulesOrUpdater: VisualPayloadRule[] | ((current: VisualPayloadRule[]) => VisualPayloadRule[]), ) => { - if ( - payloadAdvancedDirty - && typeof globalThis.confirm === 'function' - && !globalThis.confirm('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?') - ) { - return; - } - setPayloadVisualRules((currentRules) => { const nextRules = typeof nextRulesOrUpdater === 'function' ? nextRulesOrUpdater(currentRules) @@ -1629,14 +1621,14 @@ export default function Settings() { />
-
上游类型
+
协议
updatePayloadVisualRule(rule.id, { protocol: String(value || '') })} options={PAYLOAD_RULE_PROTOCOL_OPTIONS} - placeholder="全部上游类型" + placeholder="全部协议" />
diff --git a/src/web/pages/settings.payload-rules.test.tsx b/src/web/pages/settings.payload-rules.test.tsx index 43be8ba..5e212ae 100644 --- a/src/web/pages/settings.payload-rules.test.tsx +++ b/src/web/pages/settings.payload-rules.test.tsx @@ -102,7 +102,6 @@ describe('Settings payload rules', () => { afterEach(() => { vi.clearAllMocks(); - vi.unstubAllGlobals(); }); it('loads saved payload rules into the editor', async () => { @@ -328,6 +327,69 @@ describe('Settings payload rules', () => { } }); + it('keeps the full payload-rule protocol option set in the visual editor', async () => { + apiMock.getRuntimeSettings.mockResolvedValueOnce({ + checkinCron: '0 8 * * *', + checkinScheduleMode: 'interval', + checkinIntervalHours: 6, + balanceRefreshCron: '0 * * * *', + logCleanupCron: '15 4 * * *', + logCleanupUsageLogsEnabled: true, + logCleanupProgramLogsEnabled: true, + logCleanupRetentionDays: 14, + routingFallbackUnitCost: 1, + proxyFirstByteTimeoutSec: 0, + routingWeights: {}, + tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60, + adminIpAllowlist: [], + systemProxyUrl: '', + payloadRules: {}, + }); + + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const addButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).trim() === '新增规则' + )); + + await act(async () => { + addButton.props.onClick(); + }); + + const protocolSelect = root.root.find((node) => ( + node.props['data-testid'] === 'payload-rule-protocol-1' + )); + const options = Array.isArray(protocolSelect.props.options) + ? protocolSelect.props.options + : []; + const values = options.map((option: { value: string }) => option.value); + + expect(values).toEqual(expect.arrayContaining([ + '', + 'sub2api', + 'new-api', + 'one-api', + 'gemini-cli', + 'anyrouter', + ])); + } finally { + root?.unmount(); + } + }); + it('blocks save when a payload-rule section contains invalid JSON', async () => { let root!: ReactTestRenderer; try { @@ -367,81 +429,4 @@ describe('Settings payload rules', () => { root?.unmount(); } }); - - it('asks before discarding unsynced advanced JSON when switching back to visual edits', async () => { - const confirmMock = vi.fn().mockReturnValue(false); - vi.stubGlobal('confirm', confirmMock); - - apiMock.getRuntimeSettings.mockResolvedValueOnce({ - checkinCron: '0 8 * * *', - checkinScheduleMode: 'interval', - checkinIntervalHours: 6, - balanceRefreshCron: '0 * * * *', - logCleanupCron: '15 4 * * *', - logCleanupUsageLogsEnabled: true, - logCleanupProgramLogsEnabled: true, - logCleanupRetentionDays: 14, - routingFallbackUnitCost: 1, - proxyFirstByteTimeoutSec: 0, - routingWeights: {}, - tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60, - adminIpAllowlist: [], - systemProxyUrl: '', - payloadRules: {}, - }); - - let root!: ReactTestRenderer; - try { - await act(async () => { - root = create( - - - - - , - ); - }); - await flushMicrotasks(); - - const overrideTextarea = root.root.find((node) => ( - node.type === 'textarea' - && node.props['aria-label'] === 'Payload 规则 override' - )); - - const advancedDraft = `[ - { - "models": [{ "name": "gpt-*", "protocol": "codex" }], - "params": { - "text.verbosity": "low" - } - } -]`; - - await act(async () => { - overrideTextarea.props.onChange({ target: { value: advancedDraft } }); - }); - - const addButton = root.root.find((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && collectText(node).trim() === '新增规则' - )); - - await act(async () => { - addButton.props.onClick(); - }); - - expect(confirmMock).toHaveBeenCalledWith('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?'); - expect(root.root.findAll((node) => ( - node.type === 'input' - && node.props['aria-label'] === 'Payload 规则可视化模型 1' - ))).toHaveLength(0); - expect(String(root.root.find((node) => ( - node.type === 'textarea' - && node.props['aria-label'] === 'Payload 规则 override' - )).props.value)).toBe(advancedDraft); - } finally { - root?.unmount(); - } - }); }); diff --git a/src/web/pages/settings/UpdateCenterSection.test.tsx b/src/web/pages/settings/UpdateCenterSection.test.tsx index a3e524a..50dc477 100644 --- a/src/web/pages/settings/UpdateCenterSection.test.tsx +++ b/src/web/pages/settings/UpdateCenterSection.test.tsx @@ -375,6 +375,62 @@ describe('UpdateCenterSection', () => { } }); + it('deploys manual Docker Hub tags so dev and branch images are reachable from the UI', async () => { + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const manualTagInput = root.root.find((node) => ( + node.type === 'input' + && node.props.placeholder === 'dev / dev-20260417-f67ade2 / sha-f67ade2' + )); + const manualDigestInput = root.root.find((node) => ( + node.type === 'input' + && node.props.placeholder === '可选 digest:sha256:...' + )); + + await act(async () => { + manualTagInput.props.onChange({ target: { value: 'dev-20260417-f67ade2' } }); + manualDigestInput.props.onChange({ + target: { + value: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + }); + }); + + const customDeployButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('部署自定义 Docker 标签') + )); + + expect(customDeployButton.props.disabled).toBe(false); + + await act(async () => { + await customDeployButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({ + source: 'docker-hub-tag', + targetTag: 'dev-20260417-f67ade2', + targetDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object)); + } finally { + root?.unmount(); + } + }); + it('keeps rollback history compact and opens the full revision list in a centered modal', async () => { let root!: ReactTestRenderer; try { diff --git a/src/web/pages/settings/UpdateCenterSection.tsx b/src/web/pages/settings/UpdateCenterSection.tsx index 91f09ea..d9187a7 100644 --- a/src/web/pages/settings/UpdateCenterSection.tsx +++ b/src/web/pages/settings/UpdateCenterSection.tsx @@ -223,6 +223,10 @@ export default function UpdateCenterSection() { const [deploying, setDeploying] = useState(false); const [status, setStatus] = useState(null); const [config, setConfig] = useState(DEFAULT_CONFIG); + const [manualDockerTarget, setManualDockerTarget] = useState({ + tag: '', + digest: '', + }); const [logs, setLogs] = useState([]); const [taskStatus, setTaskStatus] = useState(''); const [historyModalOpen, setHistoryModalOpen] = useState(false); @@ -426,6 +430,13 @@ export default function UpdateCenterSection() { }); const canDeployGithub = !deploying && githubDeployState.canDeploy; const canDeployDocker = !deploying && dockerDeployState.canDeploy; + const manualDockerTag = String(manualDockerTarget.tag || '').trim(); + const manualDockerDigest = String(manualDockerTarget.digest || '').trim(); + const canDeployManualDocker = !deploying + && config.enabled + && config.dockerHubTagsEnabled + && helperHealthy + && !!manualDockerTag; const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : []; const historyPreview = helperHistory.slice(0, 2); const currentRevision = String(status?.helper?.revision || '').trim(); @@ -559,7 +570,7 @@ export default function UpdateCenterSection() { /> Docker Hub - 从镜像标签中找出最新稳定版本,适合镜像优先的发布链路。 + 自动发现优先稳定标签;dev / 分支 / 临时标签可在下方手动部署。
@@ -720,7 +731,7 @@ export default function UpdateCenterSection() { ) : null} -
适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。
+
自动候选优先 latest / main / 稳定 SemVer;dev / 分支 / sha 标签可手动填写部署。
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
@@ -730,21 +741,87 @@ export default function UpdateCenterSection() {
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
- +
+ + {status?.dockerHubTag?.tagName ? ( + + ) : null} +
+
+
+ 手动部署 Docker Hub 标签 +
+
+ 自动候选不会覆盖所有分支标签。若要切到 dev、feature、临时或 sha 标签,直接在这里填写。 +
+
+ setManualDockerTarget((prev) => ({ ...prev, tag: e.target.value }))} + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + placeholder="dev / dev-20260417-f67ade2 / sha-f67ade2" + /> + setManualDockerTarget((prev) => ({ ...prev, digest: e.target.value }))} + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + placeholder="可选 digest:sha256:..." + /> +
+
+ + + digest 选填;如果 chart 当前锁定了旧 digest,建议把 tag 和 digest 一起填写。 + +
+