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) =>
+ `
`,
+ )
+ .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