Merge pull request #494 from cita-777/release/feat-update-center-dev-tags-20260417
[chore] publish dev snapshot to main
This commit is contained in:
@@ -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 }})
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
let currentRoot = repoRoot;
|
||||
|
||||
while (true) {
|
||||
const hoistedEntry = resolve(currentRoot, hoistedRelativePath);
|
||||
if (existsSync(hoistedEntry)) return hoistedEntry;
|
||||
|
||||
const [pnpmEntry] = globSync(resolve(repoRoot, pnpmPattern));
|
||||
return pnpmEntry;
|
||||
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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
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(repoRoot, pnpmPattern));
|
||||
return pnpmEntry;
|
||||
};
|
||||
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',
|
||||
|
||||
@@ -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,就会跟着一起回到对应镜像
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
'<!-- metapi-contributors:start -->',
|
||||
'old block',
|
||||
'<!-- metapi-contributors:end -->',
|
||||
'',
|
||||
'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('<p align="left">');
|
||||
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 <a href="https://github.com/cita-777"');
|
||||
expect(html).toContain('\n <a href="https://github.com/Babylonehy"');
|
||||
expect((html.match(/\n <a href=/g) || []).length).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects invalid per-line values instead of looping forever', () => {
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 = '<!-- metapi-contributors:start -->';
|
||||
const END_MARKER = '<!-- metapi-contributors:end -->';
|
||||
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<string>();
|
||||
|
||||
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 '<p align="left">\n <sub>No public contributors found yet.</sub>\n</p>';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
for (let index = 0; index < contributors.length; index += perLine) {
|
||||
const chunk = contributors.slice(index, index + perLine);
|
||||
lines.push(
|
||||
` ${chunk
|
||||
.map(
|
||||
(contributor) =>
|
||||
`<a href="${contributor.htmlUrl}"><img src="${contributor.avatarUrl}" width="48" height="48" alt="${contributor.login}" title="${contributor.login}"/></a>`,
|
||||
)
|
||||
.join(' ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `<p align="left">\n${lines.join('\n')}\n</p>`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
options?: {
|
||||
sitePlatform?: string;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
const next = { ...body };
|
||||
delete next.stream;
|
||||
delete next.stream_options;
|
||||
if (shouldStripCompactResponsesStore(options?.sitePlatform)) {
|
||||
delete next.store;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function ensureCompactResponsesJsonAcceptHeader(
|
||||
headers: Record<string, string>,
|
||||
options?: {
|
||||
sitePlatform?: string;
|
||||
},
|
||||
): Record<string, string> {
|
||||
if (!shouldForceCompactResponsesJsonAccept(options?.sitePlatform)) return headers;
|
||||
const nextHeaders = { ...headers };
|
||||
delete (nextHeaders as Record<string, unknown>).Accept;
|
||||
delete (nextHeaders as Record<string, unknown>).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')
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>,
|
||||
): 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,
|
||||
};
|
||||
const detected = detectCodexOfficialClientAppFromHeaders(headers);
|
||||
return detected
|
||||
? {
|
||||
clientAppId: detected.clientAppId,
|
||||
clientAppName: detected.clientAppName,
|
||||
}
|
||||
return null;
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||
@@ -158,7 +84,10 @@ export function isCodexResponsesSurface(headers?: Record<string, unknown>): bool
|
||||
}
|
||||
|
||||
export function getCodexSessionId(headers?: Record<string, unknown>): 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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
const MAX_CODEX_SESSION_RESPONSE_IDS = 10_000;
|
||||
import { config } from '../../config.js';
|
||||
|
||||
const codexSessionResponseIds = new Map<string, string>();
|
||||
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<string, SessionResponseEntry>();
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<typeof CodexWebsocketRuntimeError>;
|
||||
expect(runtimeError.events).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
error: expect.objectContaining({
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
}
|
||||
|
||||
function getCodexSessionHeaderValue(headers: Record<string, string>): 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<string, unknown>,
|
||||
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<string, unknown>)
|
||||
? sanitizeCompactResponsesRequestBody(endpointRequest.body as Record<string, unknown>, {
|
||||
sitePlatform: selected.site.platform,
|
||||
})
|
||||
: endpointRequest.body as Record<string, unknown>
|
||||
);
|
||||
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<NonNullable<typeof endpointStrategy.tryRecover>>[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<string, string>;
|
||||
delete (recoveredHeaders as Record<string, unknown>).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,
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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 });
|
||||
});
|
||||
|
||||
it('lists multi-provider oauth metadata', async () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(');
|
||||
|
||||
@@ -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<typeof import('undici')>('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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
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: 'resp_openai_platform_claude',
|
||||
object: 'response',
|
||||
model: 'claude-opus-4-6',
|
||||
status: 'completed',
|
||||
output: [{
|
||||
id: 'msg_openai_platform_claude',
|
||||
type: 'message',
|
||||
model: 'claude-opus-4-6',
|
||||
content: [{ type: 'text', text: 'messages endpoint selected' }],
|
||||
stop_reason: 'end_turn',
|
||||
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<Uint8Array>({
|
||||
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,11 +4165,13 @@ 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: {
|
||||
@@ -3847,13 +4181,10 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
required: ['pattern'],
|
||||
},
|
||||
},
|
||||
}]);
|
||||
expect(forwardedBody.tool_choice).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {
|
||||
id: 'msg-openai-1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: 'hello from openai',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
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: {
|
||||
id: 'msg-openai-stream-1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: 'hello from openai stream fallback',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
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: {
|
||||
id: 'msg-openai-2',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: 'hello from gemini cli downstream',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
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: [
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void>>(async (_input) => undefined);
|
||||
const recordOauthQuotaResetHintMock = vi.fn<(input: unknown) => Promise<void>>(async (_input) => undefined);
|
||||
const insertedProxyLogs: Record<string, unknown>[] = [];
|
||||
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<typeof import('undici')>('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();
|
||||
|
||||
@@ -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<typeof import('undici')>('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,7 +188,17 @@ describe('responses proxy compact upstream routing', () => {
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
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',
|
||||
@@ -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<Uint8Array>({
|
||||
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<string, any>;
|
||||
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$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +195,16 @@ function waitForSocketOpen(socket: WebSocket) {
|
||||
});
|
||||
}
|
||||
|
||||
function waitForSocketClose(socket: WebSocket) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (socket.readyState === WebSocket.CLOSED) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
socket.once('close', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function waitForSocketUpgrade(socket: WebSocket) {
|
||||
return new Promise<{ headers: Record<string, string | string[] | undefined> }>((resolve, reject) => {
|
||||
socket.once('upgrade', (response) => resolve({ headers: response.headers as Record<string, string | string[] | undefined> }));
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>();
|
||||
let lastRequest: Record<string, unknown> | null = null;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string> = {
|
||||
Originator: originator,
|
||||
};
|
||||
|
||||
+45
-1
@@ -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: {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -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)', () => {
|
||||
+2
-2
@@ -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;
|
||||
+1
-1
@@ -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', () => {
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
parseProxyLogMetadata,
|
||||
type ParsedProxyLogMetadata,
|
||||
type ProxyLogUsageSource,
|
||||
} from '../../../shared/proxyLogMeta.js';
|
||||
} from '../../shared/proxyLogMeta.js';
|
||||
|
||||
type ComposeProxyLogMessageArgs = {
|
||||
clientKind?: string | null;
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise<any> => 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<UpstreamEndpoint>();
|
||||
|
||||
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<UpstreamEndpoint[]> {
|
||||
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<UpstreamEndpoint>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function resolveRequestedModelForPayloadRules(input: {
|
||||
modelName: string;
|
||||
openaiBody: Record<string, unknown>;
|
||||
claudeOriginalBody?: Record<string, unknown>;
|
||||
responsesOriginalBody?: Record<string, unknown>;
|
||||
}): 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<string, unknown>,
|
||||
): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
|
||||
const forwarded: Record<string, string> = {};
|
||||
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<string, unknown>,
|
||||
): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
|
||||
const forwarded: Record<string, string> = {};
|
||||
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<string, unknown>,
|
||||
): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
|
||||
const forwarded: Record<string, string> = {};
|
||||
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<string, unknown>,
|
||||
): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
|
||||
const forwarded: Record<string, string> = {};
|
||||
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<string, unknown>): {
|
||||
body: Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...body };
|
||||
delete next.previous_response_id;
|
||||
delete next.prompt_cache_key;
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildAntigravityRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
stream: boolean;
|
||||
}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
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<string, string>,
|
||||
stream: boolean,
|
||||
): Record<string, string> {
|
||||
if (!stream) return headers;
|
||||
|
||||
const existingAccept = (
|
||||
headerValueToString(headers.accept)
|
||||
|| headerValueToString((headers as Record<string, unknown>).Accept)
|
||||
);
|
||||
if (existingAccept) return headers;
|
||||
|
||||
return {
|
||||
...headers,
|
||||
accept: 'text/event-stream',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureResponsesAcceptHeader(
|
||||
headers: Record<string, string>,
|
||||
input: {
|
||||
stream: boolean;
|
||||
sitePlatform?: string;
|
||||
},
|
||||
): Record<string, string> {
|
||||
const nextHeaders = { ...headers };
|
||||
delete (nextHeaders as Record<string, unknown>).Accept;
|
||||
delete (nextHeaders as Record<string, unknown>).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<string, unknown> | 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<string, unknown> = { 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<string>,
|
||||
): 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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...body };
|
||||
const normalizedTools = Array.isArray(body.tools)
|
||||
? body.tools
|
||||
.map((tool) => normalizeResponsesFallbackChatFunctionTool(tool))
|
||||
.filter((tool): tool is Record<string, unknown> => !!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<string, unknown>,
|
||||
sitePlatform: string,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
downstreamFormat: DownstreamFormat | 'responses';
|
||||
claudeOriginalBody?: Record<string, unknown>;
|
||||
forceNormalizeClaudeBody?: boolean;
|
||||
responsesOriginalBody?: Record<string, unknown>;
|
||||
downstreamHeaders?: Record<string, unknown>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
codexSessionCacheKey?: string | null;
|
||||
codexExplicitSessionId?: string | null;
|
||||
}): {
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
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<string, string> = {
|
||||
...passthroughHeaders,
|
||||
...codexPassthroughHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
...(input.providerHeaders || {}),
|
||||
};
|
||||
if (!isClaudeUpstream) {
|
||||
commonHeaders.Authorization = `Bearer ${input.tokenValue}`;
|
||||
}
|
||||
|
||||
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
|
||||
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 = <T extends Record<string, unknown>>(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<string, unknown>;
|
||||
downstreamHeaders?: Record<string, unknown>;
|
||||
}): {
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
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',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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<string, unknown>,
|
||||
): 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, unknown>,
|
||||
): string | null {
|
||||
return detectCodexOfficialClientApp(headers)?.originator || null;
|
||||
}
|
||||
|
||||
export function isCodexOfficialClientHeaders(
|
||||
headers?: Record<string, unknown>,
|
||||
): 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,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type DownstreamFormat } from '../../shared/normalized.js';
|
||||
import { inferRequiredEndpointFromProtocolError } from '../../shared/endpointCompatibility.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream);
|
||||
return buildCanonicalRequestToAnthropicMessagesBody(request);
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof anthropicMessagesInbound.parse> {
|
||||
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<typeof anthropicMessagesOutbound.serializeFinal>[1],
|
||||
usage: Parameters<typeof buildNormalizedFinalToAnthropicMessagesBody>[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,
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<T>(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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> = [];
|
||||
const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature);
|
||||
if (normalized.reasoningContent || cleanSignature) {
|
||||
const thinkingBlock: Record<string, unknown> = {
|
||||
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';
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<T>(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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> = [];
|
||||
const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature);
|
||||
if (normalized.reasoningContent || cleanSignature) {
|
||||
const thinkingBlock: Record<string, unknown> = {
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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."');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown> = {
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
continuation: CanonicalContinuation | undefined,
|
||||
): Record<string, unknown> {
|
||||
const normalized = normalizeCanonicalContinuation(continuation);
|
||||
const nextMetadata: Record<string, unknown> = { ...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<string, unknown>,
|
||||
continuation: CanonicalContinuation | undefined,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
attachments?: CanonicalAttachment[];
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(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[] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
operation?: CanonicalOperation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
continuation?: CanonicalContinuation;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(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<string, unknown>,
|
||||
): 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<string, unknown> };
|
||||
}
|
||||
|
||||
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<string, unknown> };
|
||||
}
|
||||
if (name) return { type: 'tool', name };
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
}];
|
||||
}
|
||||
|
||||
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<string | Record<string, unknown>> }
|
||||
: (isRecord(rawContent)
|
||||
? { resultContent: [cloneJsonValue(rawContent) as Record<string, unknown>] }
|
||||
: {})),
|
||||
}],
|
||||
});
|
||||
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<Record<string, unknown>>; reasoning?: string; toolCalls?: Array<Record<string, unknown>> } {
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const toolCalls: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
|
||||
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<string, unknown> = {
|
||||
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<unknown>).length <= 0) {
|
||||
nextMessage.content = '';
|
||||
}
|
||||
}
|
||||
messages.push(nextMessage);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown>
|
||||
: 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
attachments?: CanonicalAttachment[];
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(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<string, unknown>;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
operation?: CanonicalOperation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
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<string, unknown> };
|
||||
}
|
||||
|
||||
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<string, unknown> };
|
||||
}
|
||||
if (name) return { type: 'tool', name };
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
}];
|
||||
}
|
||||
|
||||
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<string | Record<string, unknown>> }
|
||||
: (isRecord(rawContent)
|
||||
? { resultContent: [cloneJsonValue(rawContent) as Record<string, unknown>] }
|
||||
: {})),
|
||||
}],
|
||||
});
|
||||
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<Record<string, unknown>>; reasoning?: string; toolCalls?: Array<Record<string, unknown>> } {
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const toolCalls: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
|
||||
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<string, unknown> = {
|
||||
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<unknown>).length <= 0) {
|
||||
nextMessage.content = '';
|
||||
}
|
||||
}
|
||||
messages.push(nextMessage);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown>
|
||||
: 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<string, unknown>;
|
||||
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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TextDecoder, TextEncoder } from 'node:util';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function wrapGeminiCliRequest(input: {
|
||||
modelName: string;
|
||||
projectId: string;
|
||||
request: Record<string, unknown>;
|
||||
}) {
|
||||
const { model: _model, ...requestPayload } = input.request;
|
||||
return {
|
||||
project: input.projectId,
|
||||
model: input.modelName,
|
||||
request: requestPayload,
|
||||
};
|
||||
}
|
||||
|
||||
export function unwrapGeminiCliPayload<T>(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<unknown>;
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> | 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<string, unknown> {
|
||||
const contents: Array<Record<string, unknown>> = [];
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
|
||||
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<string, unknown> => !!part),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!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<string, unknown> = {
|
||||
contents,
|
||||
};
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
payload.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
if (request.reasoning?.budgetTokens !== undefined) {
|
||||
generationConfig.thinkingConfig = {
|
||||
thinkingBudget: request.reasoning.budgetTokens,
|
||||
};
|
||||
} else if (request.reasoning?.effort) {
|
||||
generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig(
|
||||
request.requestedModel,
|
||||
request.reasoning.effort,
|
||||
);
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
payload.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
const functionTools = request.tools.filter(isCanonicalFunctionTool);
|
||||
if (functionTools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: functionTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (request.toolChoice) {
|
||||
if (request.toolChoice === 'none') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (request.toolChoice === 'auto') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (request.toolChoice === 'required') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
} else if (isCanonicalNamedToolChoice(request.toolChoice)) {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [request.toolChoice.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
import {
|
||||
buildCanonicalRequestToGeminiGenerateContentBody,
|
||||
parseGeminiGenerateContentRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
export const geminiGenerateContentTransformer = {
|
||||
protocol: 'gemini/generate-content' as const,
|
||||
inbound: geminiGenerateContentInbound,
|
||||
outbound: geminiGenerateContentOutbound,
|
||||
outbound: geminiGenerateContentResponseBridge,
|
||||
stream: geminiGenerateContentStream,
|
||||
aggregator: {
|
||||
createState: createGeminiGenerateContentAggregateState,
|
||||
@@ -281,53 +90,19 @@ export const geminiGenerateContentTransformer = {
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const rawBody = isRecord(body) ? body : {};
|
||||
const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'model is required',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stream = rawBody.stream === true || ctx?.metadata?.stream === true;
|
||||
const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel);
|
||||
const openAiBody = buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody,
|
||||
modelName: requestedModel,
|
||||
stream,
|
||||
});
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
return parseGeminiGenerateContentRequestToCanonical(body, ctx);
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
return buildGeminiRequestFromCanonical(request);
|
||||
return buildCanonicalRequestToGeminiGenerateContentBody(request);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
geminiGenerateContentInbound,
|
||||
geminiGenerateContentOutbound,
|
||||
geminiGenerateContentResponseBridge as geminiGenerateContentOutbound,
|
||||
geminiGenerateContentStream,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
applyGeminiGenerateContentAggregate,
|
||||
|
||||
@@ -1,279 +1,15 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import {
|
||||
toTransformerMetadataRecord,
|
||||
type TransformerMetadata,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
resolveGeminiGenerateContentUrl,
|
||||
resolveGeminiModelsUrl,
|
||||
resolveGeminiNativeBaseUrl,
|
||||
} from './urlResolver.js';
|
||||
extractGeminiGenerateContentResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata,
|
||||
geminiGenerateContentResponseBridge,
|
||||
serializeGeminiGenerateContentAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
export const geminiGenerateContentOutbound = geminiGenerateContentResponseBridge;
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
|
||||
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
|
||||
}
|
||||
|
||||
function ensureAggregateState(payload: unknown): GeminiGenerateContentAggregateState {
|
||||
if (isAggregateState(payload)) return payload;
|
||||
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
const chunks = Array.isArray(payload) ? payload : [payload];
|
||||
for (const chunk of chunks) {
|
||||
applyGeminiGenerateContentAggregate(state, chunk);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildUsageMetadata(state: GeminiGenerateContentAggregateState): GeminiRecord | undefined {
|
||||
const usage = state.usage;
|
||||
const next: GeminiRecord = {};
|
||||
if (typeof usage.promptTokenCount === 'number') next.promptTokenCount = usage.promptTokenCount;
|
||||
if (typeof usage.candidatesTokenCount === 'number') next.candidatesTokenCount = usage.candidatesTokenCount;
|
||||
if (typeof usage.totalTokenCount === 'number') next.totalTokenCount = usage.totalTokenCount;
|
||||
if (typeof usage.cachedContentTokenCount === 'number') next.cachedContentTokenCount = usage.cachedContentTokenCount;
|
||||
if (typeof usage.thoughtsTokenCount === 'number') next.thoughtsTokenCount = usage.thoughtsTokenCount;
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => {
|
||||
const next: GeminiRecord = {
|
||||
index: candidate.index,
|
||||
finishReason: candidate.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: candidate.parts,
|
||||
},
|
||||
};
|
||||
if (candidate.groundingMetadata) next.groundingMetadata = candidate.groundingMetadata;
|
||||
if (candidate.citationMetadata) next.citationMetadata = candidate.citationMetadata;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const fallback: GeminiRecord = {
|
||||
index: 0,
|
||||
finishReason: state.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: state.parts,
|
||||
},
|
||||
};
|
||||
if (state.groundingMetadata.length > 0) {
|
||||
fallback.groundingMetadata = state.groundingMetadata[0];
|
||||
}
|
||||
if (state.citations.length > 0) {
|
||||
fallback.citationMetadata = state.citations[0];
|
||||
}
|
||||
return [fallback];
|
||||
}
|
||||
|
||||
function extractOrderedCandidateMetadata(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
key: 'groundingMetadata' | 'citationMetadata',
|
||||
): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate[key])
|
||||
.filter((item): item is GeminiRecord => isRecord(item))
|
||||
.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
const fallback = key === 'groundingMetadata' ? state.groundingMetadata : state.citations;
|
||||
return fallback.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
function extractOrderedThoughtSignatures(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
): {
|
||||
all: string[];
|
||||
preferred: string | undefined;
|
||||
} {
|
||||
const ordered = new Set<string>();
|
||||
const preferredThoughts = new Set<string>();
|
||||
|
||||
const candidates = state.candidates.length > 0
|
||||
? state.candidates.slice().sort((left, right) => left.index - right.index)
|
||||
: [{ index: 0, finishReason: state.finishReason, parts: state.parts }];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const part of candidate.parts) {
|
||||
if (!isRecord(part) || typeof part.thoughtSignature !== 'string' || !part.thoughtSignature.trim()) {
|
||||
continue;
|
||||
}
|
||||
ordered.add(part.thoughtSignature);
|
||||
if (part.thought === true) {
|
||||
preferredThoughts.add(part.thoughtSignature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedList = [...ordered];
|
||||
const preferred = [...preferredThoughts][0]
|
||||
?? (orderedList.length === 1 ? orderedList[0] : undefined);
|
||||
|
||||
return {
|
||||
all: orderedList.length > 0 ? orderedList : [...state.thoughtSignatures],
|
||||
preferred,
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePassthrough(metadata: TransformerMetadata): Record<string, unknown> {
|
||||
if (!metadata.passthrough) metadata.passthrough = {};
|
||||
return metadata.passthrough;
|
||||
}
|
||||
|
||||
function extractRequestSemantics(requestPayload: unknown): TransformerMetadata {
|
||||
if (!isRecord(requestPayload)) return {};
|
||||
|
||||
const metadata: TransformerMetadata = {};
|
||||
const passthrough = ensurePassthrough(metadata);
|
||||
if (requestPayload.systemInstruction !== undefined) {
|
||||
passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||
}
|
||||
if (requestPayload.cachedContent !== undefined) {
|
||||
passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||
}
|
||||
if (requestPayload.safetySettings !== undefined) {
|
||||
metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||
}
|
||||
if (requestPayload.toolConfig !== undefined) {
|
||||
passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||
}
|
||||
|
||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||
if (generationConfig) {
|
||||
const preservedKeys = [
|
||||
'stopSequences',
|
||||
'responseModalities',
|
||||
'responseMimeType',
|
||||
'responseSchema',
|
||||
'candidateCount',
|
||||
'maxOutputTokens',
|
||||
'temperature',
|
||||
'topP',
|
||||
'topK',
|
||||
'presencePenalty',
|
||||
'frequencyPenalty',
|
||||
'seed',
|
||||
'responseLogprobs',
|
||||
'logprobs',
|
||||
'thinkingConfig',
|
||||
'imageConfig',
|
||||
];
|
||||
for (const key of preservedKeys) {
|
||||
if (generationConfig[key] !== undefined) {
|
||||
if (key === 'imageConfig') {
|
||||
metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]);
|
||||
} else {
|
||||
passthrough[key] = cloneJsonValue(generationConfig[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(requestPayload.tools)) {
|
||||
const requestTools = requestPayload.tools
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch);
|
||||
if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext);
|
||||
if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution);
|
||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations);
|
||||
return next;
|
||||
})
|
||||
.filter((item) => Object.keys(item).length > 0);
|
||||
|
||||
if (requestTools.length > 0) {
|
||||
passthrough.tools = requestTools;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) {
|
||||
delete metadata.passthrough;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function serializeGeminiAggregateResponse(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): GeminiRecord {
|
||||
const state = ensureAggregateState(payload);
|
||||
const response: GeminiRecord = {
|
||||
responseId: state.responseId || '',
|
||||
modelVersion: state.modelVersion || '',
|
||||
candidates: buildCandidates(state),
|
||||
};
|
||||
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) {
|
||||
response.usageMetadata = usageMetadata;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function extractTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata {
|
||||
const state = ensureAggregateState(payload);
|
||||
const metadata = extractRequestSemantics(requestPayload);
|
||||
|
||||
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
|
||||
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
|
||||
const thoughtSignatures = extractOrderedThoughtSignatures(state);
|
||||
|
||||
if (citations.length > 0) metadata.citations = citations;
|
||||
if (groundingMetadata.length > 0) metadata.groundingMetadata = groundingMetadata;
|
||||
if (thoughtSignatures.all.length > 0) {
|
||||
if (thoughtSignatures.preferred) {
|
||||
metadata.thoughtSignature = thoughtSignatures.preferred;
|
||||
}
|
||||
metadata.thoughtSignatures = thoughtSignatures.all;
|
||||
}
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) metadata.usageMetadata = usageMetadata;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
||||
return toTransformerMetadataRecord(extractTransformerMetadata(payload, requestPayload)) ?? {};
|
||||
}
|
||||
|
||||
export const geminiGenerateContentOutbound = {
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
extractTransformerMetadata,
|
||||
extractResponseMetadata,
|
||||
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
||||
};
|
||||
export {
|
||||
geminiGenerateContentResponseBridge,
|
||||
extractGeminiGenerateContentResponseMetadata as extractResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata as extractTransformerMetadata,
|
||||
serializeGeminiGenerateContentAggregateResponse as serializeGeminiAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGeminiGenerateContentRequestFromOpenAi,
|
||||
buildCanonicalRequestToGeminiGenerateContentBody,
|
||||
parseGeminiGenerateContentRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
describe('gemini generate-content request bridge', () => {
|
||||
it('parses Gemini generateContent bodies into canonical envelopes', () => {
|
||||
const result = parseGeminiGenerateContentRequestToCanonical({
|
||||
model: 'gemini-2.5-pro',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
surface: 'gemini-generate-content',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Gemini generateContent bodies from canonical envelopes', () => {
|
||||
const body = buildCanonicalRequestToGeminiGenerateContentBody({
|
||||
operation: 'generate',
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: 'gemini_cli',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
tools: [{ name: 'lookup', inputSchema: { type: 'object' } }],
|
||||
toolChoice: 'required',
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookup',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
},
|
||||
},
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('injects provider thought signatures into functionCall parts', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me check.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
|
||||
provider_specific_fields: { thought_signature: 'real_sig_abc' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(functionCallParts[0].thoughtSignature).toBe('real_sig_abc');
|
||||
});
|
||||
|
||||
it('splits text and signed functionCall parts into separate model messages', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read the file.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'I will read it.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_456',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/tmp/x"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_split_test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_456', content: 'file content here' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
|
||||
expect(modelMessages).toHaveLength(2);
|
||||
expect((modelMessages[0].parts as Array<Record<string, unknown>>).every((part) => 'text' in part)).toBe(true);
|
||||
expect((modelMessages[1].parts as Array<Record<string, unknown>>).every((part) => 'functionCall' in part)).toBe(true);
|
||||
expect((modelMessages[1].parts as Array<Record<string, unknown>>)[0].thoughtSignature).toBe('sig_split_test');
|
||||
});
|
||||
|
||||
it('disables thinking config when signatures are missing for non-gemini targets', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
body: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig_non_gemini',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig_non_gemini', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(functionCallParts[0].thoughtSignature).toBeUndefined();
|
||||
expect((result.generationConfig as Record<string, unknown> | undefined)?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('injects a dummy thought signature when thinking is enabled and no signature is available', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(typeof functionCallParts[0].thoughtSignature).toBe('string');
|
||||
expect((functionCallParts[0].thoughtSignature as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('preserves functionResponse count matching functionCall count', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read two files.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_a',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/a"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_a' },
|
||||
},
|
||||
{
|
||||
id: 'call_b',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/b"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_b' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_a', content: 'content a' },
|
||||
{ role: 'tool', tool_call_id: 'call_b', content: 'content b' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
let functionCallCount = 0;
|
||||
let functionResponseCount = 0;
|
||||
for (const content of contents) {
|
||||
for (const part of (content.parts as Array<Record<string, unknown>>)) {
|
||||
if ('functionCall' in part) functionCallCount += 1;
|
||||
if ('functionResponse' in part) functionResponseCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
expect(functionCallCount).toBe(2);
|
||||
expect(functionResponseCount).toBe(2);
|
||||
});
|
||||
|
||||
it('uses canonical tool-call names for Gemini functionResponse parts', () => {
|
||||
const body = buildCanonicalRequestToGeminiGenerateContentBody({
|
||||
operation: 'generate',
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [{
|
||||
type: 'tool_call',
|
||||
id: 'call_weather',
|
||||
name: 'lookup_weather',
|
||||
argumentsJson: '{"city":"Paris"}',
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: 'call_weather',
|
||||
resultJson: { temperature: '22C' },
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(body.contents).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{
|
||||
functionCall: {
|
||||
id: 'call_weather',
|
||||
name: 'lookup_weather',
|
||||
args: { city: 'Paris' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{
|
||||
functionResponse: {
|
||||
name: 'lookup_weather',
|
||||
response: {
|
||||
result: { temperature: '22C' },
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+35
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildGeminiGenerateContentRequestFromOpenAi } from './geminiCliCompat.js';
|
||||
import { buildGeminiGenerateContentRequestFromOpenAi } from './requestBridge.js';
|
||||
|
||||
describe('thoughtSignature injection in OpenAI→Gemini conversion', () => {
|
||||
it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => {
|
||||
@@ -148,6 +148,40 @@ describe('thoughtSignature injection in OpenAI→Gemini conversion', () => {
|
||||
expect(fcParts[0].thoughtSignature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not inject dummy signature or preserve thinkingConfig for non-gemini targets when signature is missing', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig_non_gemini',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig_non_gemini', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
expect(fcParts[0].thoughtSignature).toBeUndefined();
|
||||
expect((result.generationConfig as Record<string, unknown> | undefined)?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves functionResponse count matching functionCall count', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
+266
-110
@@ -1,19 +1,51 @@
|
||||
import { TextDecoder, TextEncoder } from 'node:util';
|
||||
import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js';
|
||||
|
||||
// Dummy sentinel used when no real thoughtSignature is available but thinking
|
||||
// mode is enabled. Gemini accepts any base64 string and won't reject this.
|
||||
const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
import { canonicalRequestFromOpenAiBody } from '../../canonical/openAiRequestBridge.js';
|
||||
import { isCanonicalFunctionTool, isCanonicalNamedToolChoice } from '../../canonical/tools.js';
|
||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolParseContext } from '../../contracts.js';
|
||||
import { geminiGenerateContentInbound } from './inbound.js';
|
||||
import { buildOpenAiBodyFromGeminiRequest } from './compatibility.js';
|
||||
import {
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
resolveGeminiThinkingConfigFromRequest,
|
||||
} from './convert.js';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseDataUrl(url: string): { mimeType: string; data: string } | null {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { raw };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataUrl(value: string): { mimeType: string; data: string } | null {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1],
|
||||
data: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Dummy sentinel used when no real thoughtSignature is available but thinking
|
||||
// mode is enabled. Gemini accepts any base64 string and won't reject this.
|
||||
const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=';
|
||||
|
||||
function isDummyThoughtSafeModel(modelName: string): boolean {
|
||||
const normalized = asTrimmedString(modelName).toLowerCase();
|
||||
return normalized.startsWith('gemini-') || normalized.startsWith('models/gemini-');
|
||||
}
|
||||
|
||||
function parseInlineDataUrl(url: string): { mimeType: string; data: string } | null {
|
||||
if (!url.startsWith('data:')) return null;
|
||||
const [, rest] = url.split('data:', 2);
|
||||
const [meta, data] = rest.split(',', 2);
|
||||
@@ -36,7 +68,7 @@ function normalizeFunctionResponseResult(value: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function convertContentToGeminiParts(content: unknown): Array<Record<string, unknown>> {
|
||||
function convertOpenAiContentToGeminiParts(content: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof content === 'string') {
|
||||
const trimmed = content.trim();
|
||||
return trimmed ? [{ text: trimmed }] : [];
|
||||
@@ -63,7 +95,7 @@ function convertContentToGeminiParts(content: unknown): Array<Record<string, unk
|
||||
}
|
||||
if (type === 'image_url') {
|
||||
const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.url);
|
||||
const parsed = imageUrl ? parseDataUrl(imageUrl) : null;
|
||||
const parsed = imageUrl ? parseInlineDataUrl(imageUrl) : null;
|
||||
if (parsed) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
@@ -89,7 +121,7 @@ function convertContentToGeminiParts(content: unknown): Array<Record<string, unk
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildGeminiTools(tools: unknown): Array<Record<string, unknown>> | undefined {
|
||||
function buildGeminiToolsFromOpenAi(tools: unknown): Array<Record<string, unknown>> | undefined {
|
||||
if (!Array.isArray(tools)) return undefined;
|
||||
const declarations = tools
|
||||
.filter((item) => isRecord(item))
|
||||
@@ -109,7 +141,7 @@ function buildGeminiTools(tools: unknown): Array<Record<string, unknown>> | unde
|
||||
return [{ functionDeclarations: declarations }];
|
||||
}
|
||||
|
||||
function buildGeminiToolConfig(toolChoice: unknown): Record<string, unknown> | undefined {
|
||||
function buildGeminiToolConfigFromOpenAi(toolChoice: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof toolChoice === 'string') {
|
||||
const normalized = toolChoice.trim().toLowerCase();
|
||||
if (normalized === 'none') {
|
||||
@@ -147,11 +179,10 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if thinking mode is enabled (needed for dummy signature fallback)
|
||||
const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
|
||||
const allowsDummyThoughtSignature = isDummyThoughtSafeModel(input.modelName);
|
||||
let shouldDisableThinkingConfig = false;
|
||||
|
||||
// Collect thoughtSignatures from assistant tool_calls for injection.
|
||||
// OpenAI format stores signatures in provider_specific_fields or encoded in tool call IDs.
|
||||
const thoughtSignatureById = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
|
||||
@@ -160,7 +191,6 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
if (!id) continue;
|
||||
// Check provider_specific_fields first
|
||||
const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null;
|
||||
if (providerFields && typeof providerFields.thought_signature === 'string') {
|
||||
thoughtSignatureById.set(id, providerFields.thought_signature);
|
||||
@@ -177,7 +207,7 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
if (!isRecord(message)) continue;
|
||||
const role = asTrimmedString(message.role).toLowerCase();
|
||||
if (role === 'system' || role === 'developer') {
|
||||
systemParts.push(...convertContentToGeminiParts(message.content));
|
||||
systemParts.push(...convertOpenAiContentToGeminiParts(message.content));
|
||||
continue;
|
||||
}
|
||||
if (role === 'tool') {
|
||||
@@ -201,7 +231,7 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const textParts = convertContentToGeminiParts(message.content);
|
||||
const textParts = convertOpenAiContentToGeminiParts(message.content);
|
||||
const fcParts: Array<Record<string, unknown>> = [];
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
@@ -222,29 +252,21 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
const fcPart: Record<string, unknown> = {
|
||||
functionCall: { name, args },
|
||||
};
|
||||
// Inject thoughtSignature if available from provider_specific_fields.
|
||||
// If no signature is found, use a dummy sentinel so Gemini doesn't reject
|
||||
// the request outright. Gemini 3+ requires thoughtSignature on all
|
||||
// functionCall parts when thinking is enabled.
|
||||
// Ref: https://ai.google.dev/gemini-api/docs/thought-signatures
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const signature = thoughtSignatureById.get(id);
|
||||
if (signature) {
|
||||
fcPart.thoughtSignature = signature;
|
||||
} else if (hasThinkingEnabled) {
|
||||
} else if (hasThinkingEnabled && allowsDummyThoughtSignature) {
|
||||
fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE;
|
||||
} else if (hasThinkingEnabled) {
|
||||
shouldDisableThinkingConfig = true;
|
||||
}
|
||||
fcParts.push(fcPart);
|
||||
}
|
||||
|
||||
const geminiRole = role === 'assistant' ? 'model' : 'user';
|
||||
|
||||
// Gemini requires that parts WITH thoughtSignature and parts WITHOUT
|
||||
// are not mixed in the same message. Split if needed.
|
||||
// Ref: https://ai.google.dev/gemini-api/docs/thought-signatures
|
||||
const hasSigned = fcParts.some((p) => 'thoughtSignature' in p);
|
||||
const hasSigned = fcParts.some((part) => 'thoughtSignature' in part);
|
||||
if (hasSigned && textParts.length > 0 && fcParts.length > 0) {
|
||||
// Emit text parts first, then functionCall parts in a separate message
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{ role: geminiRole, parts: textParts },
|
||||
@@ -287,18 +309,18 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
|
||||
if (thinkingConfig) {
|
||||
if (thinkingConfig && !shouldDisableThinkingConfig) {
|
||||
generationConfig.thinkingConfig = thinkingConfig;
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
request.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
const geminiTools = buildGeminiTools(input.body.tools);
|
||||
const geminiTools = buildGeminiToolsFromOpenAi(input.body.tools);
|
||||
if (geminiTools) {
|
||||
request.tools = geminiTools;
|
||||
}
|
||||
const toolConfig = buildGeminiToolConfig(input.body.tool_choice);
|
||||
const toolConfig = buildGeminiToolConfigFromOpenAi(input.body.tool_choice);
|
||||
if (toolConfig) {
|
||||
request.toolConfig = toolConfig;
|
||||
}
|
||||
@@ -306,91 +328,225 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
return request;
|
||||
}
|
||||
|
||||
export function wrapGeminiCliRequest(input: {
|
||||
modelName: string;
|
||||
projectId: string;
|
||||
request: Record<string, unknown>;
|
||||
}) {
|
||||
const { model, ...requestPayload } = input.request;
|
||||
function canonicalPartToGeminiPart(
|
||||
part: CanonicalContentPart,
|
||||
toolNameById?: ReadonlyMap<string, string>,
|
||||
): Record<string, unknown> | null {
|
||||
if (part.type === 'text') {
|
||||
return {
|
||||
project: input.projectId,
|
||||
model: input.modelName,
|
||||
request: requestPayload,
|
||||
text: part.text,
|
||||
...(part.thought === true ? { thought: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function unwrapGeminiCliPayload<T>(payload: T): unknown {
|
||||
if (!isRecord(payload)) return payload;
|
||||
if (payload.response !== undefined) {
|
||||
return payload.response;
|
||||
if (part.type === 'image') {
|
||||
const source = typeof part.dataUrl === 'string' && part.dataUrl.trim()
|
||||
? part.dataUrl
|
||||
: (typeof part.url === 'string' ? part.url : '');
|
||||
if (!source) return null;
|
||||
|
||||
const dataUrl = parseDataUrl(source);
|
||||
if (dataUrl) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: dataUrl.mimeType,
|
||||
data: dataUrl.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: source,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'file') {
|
||||
if (part.fileData) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
data: part.fileData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileUri = part.fileUrl || part.fileId;
|
||||
if (!fileUri) return null;
|
||||
return {
|
||||
fileData: {
|
||||
fileUri,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_call') {
|
||||
return {
|
||||
functionCall: {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
args: parseJsonString(part.argumentsJson),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_result') {
|
||||
const response = part.resultJson ?? parseJsonString(part.resultText ?? '');
|
||||
return {
|
||||
functionResponse: {
|
||||
name: toolNameById?.get(part.toolCallId) || 'unknown',
|
||||
response: {
|
||||
result: response,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCanonicalRequestToGeminiGenerateContentBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const contents: Array<Record<string, unknown>> = [];
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
const toolNameById = new Map<string, string>();
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'system' || message.role === 'developer') {
|
||||
systemParts.push(
|
||||
...message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part, toolNameById))
|
||||
.filter((part): part is Record<string, unknown> => !!part),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === 'tool_call') {
|
||||
toolNameById.set(part.id, part.name);
|
||||
}
|
||||
}
|
||||
|
||||
const parts = message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part, toolNameById))
|
||||
.filter((part): part is Record<string, unknown> => !!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<string, unknown> = {
|
||||
contents,
|
||||
};
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
payload.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
if (request.reasoning?.budgetTokens !== undefined) {
|
||||
generationConfig.thinkingConfig = {
|
||||
thinkingBudget: request.reasoning.budgetTokens,
|
||||
};
|
||||
} else if (request.reasoning?.effort) {
|
||||
generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig(
|
||||
request.requestedModel,
|
||||
request.reasoning.effort,
|
||||
);
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
payload.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
const functionTools = request.tools.filter(isCanonicalFunctionTool);
|
||||
if (functionTools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: functionTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (request.toolChoice) {
|
||||
if (request.toolChoice === 'none') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (request.toolChoice === 'auto') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (request.toolChoice === 'required') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
} else if (isCanonicalNamedToolChoice(request.toolChoice)) {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [request.toolChoice.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function rewriteGeminiCliSseEventBlock(block: string): string {
|
||||
const lines = block.split(/\r?\n/g);
|
||||
return lines.map((line) => {
|
||||
if (!line.startsWith('data:')) return line;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') return line;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`;
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function createGeminiCliStreamReader(reader: {
|
||||
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
||||
cancel(reason?: unknown): Promise<unknown>;
|
||||
releaseLock(): void;
|
||||
}) {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const outputQueue: Uint8Array[] = [];
|
||||
let buffer = '';
|
||||
let done = false;
|
||||
|
||||
async function fillQueue() {
|
||||
while (outputQueue.length <= 0 && !done) {
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
done = true;
|
||||
const tail = decoder.decode();
|
||||
if (tail) buffer += tail;
|
||||
if (buffer.trim()) {
|
||||
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`));
|
||||
buffer = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!result.value) continue;
|
||||
buffer += decoder.decode(result.value, { stream: true });
|
||||
let separatorIndex = buffer.indexOf('\n\n');
|
||||
while (separatorIndex >= 0) {
|
||||
const block = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`));
|
||||
separatorIndex = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGeminiGenerateContentRequestToCanonical(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const rawBody = isRecord(body) ? body : {};
|
||||
const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return {
|
||||
async read() {
|
||||
await fillQueue();
|
||||
if (outputQueue.length > 0) {
|
||||
return { done: false, value: outputQueue.shift() };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'model is required',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
cancel(reason?: unknown) {
|
||||
return reader.cancel(reason);
|
||||
},
|
||||
releaseLock() {
|
||||
reader.releaseLock();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stream = rawBody.stream === true || ctx?.metadata?.stream === true;
|
||||
const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel);
|
||||
const openAiBody = buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody,
|
||||
modelName: requestedModel,
|
||||
stream,
|
||||
});
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractGeminiGenerateContentResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata,
|
||||
geminiGenerateContentResponseBridge,
|
||||
serializeGeminiGenerateContentAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
import { geminiGenerateContentOutbound } from './outbound.js';
|
||||
|
||||
describe('gemini generate-content response bridge', () => {
|
||||
it('serializes aggregate responses with usage metadata', () => {
|
||||
const payload = serializeGeminiGenerateContentAggregateResponse({
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
finishReason: 'STOP',
|
||||
parts: [{ text: 'hello' }],
|
||||
candidates: [],
|
||||
groundingMetadata: [],
|
||||
citations: [],
|
||||
thoughtSignatures: [],
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
}],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts transformer and response metadata from aggregate payloads', () => {
|
||||
const payload = {
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
finishReason: 'STOP',
|
||||
parts: [{ text: 'hello', thoughtSignature: 'sig-1', thought: true }],
|
||||
candidates: [],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractGeminiGenerateContentTransformerMetadata(payload, {
|
||||
systemInstruction: { parts: [{ text: 'system prompt' }] },
|
||||
})).toMatchObject({
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
thoughtSignature: 'sig-1',
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
passthrough: {
|
||||
systemInstruction: { parts: [{ text: 'system prompt' }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(extractGeminiGenerateContentResponseMetadata(payload)).toMatchObject({
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
thoughtSignature: 'sig-1',
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the outbound facade pointed at the response bridge object', () => {
|
||||
expect(geminiGenerateContentOutbound).toBe(geminiGenerateContentResponseBridge);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import {
|
||||
toTransformerMetadataRecord,
|
||||
type TransformerMetadata,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
resolveGeminiGenerateContentUrl,
|
||||
resolveGeminiModelsUrl,
|
||||
resolveGeminiNativeBaseUrl,
|
||||
} from './urlResolver.js';
|
||||
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
|
||||
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
|
||||
}
|
||||
|
||||
function ensureAggregateState(payload: unknown): GeminiGenerateContentAggregateState {
|
||||
if (isAggregateState(payload)) return payload;
|
||||
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
const chunks = Array.isArray(payload) ? payload : [payload];
|
||||
for (const chunk of chunks) {
|
||||
applyGeminiGenerateContentAggregate(state, chunk);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildUsageMetadata(state: GeminiGenerateContentAggregateState): GeminiRecord | undefined {
|
||||
const usage = state.usage;
|
||||
const next: GeminiRecord = {};
|
||||
if (typeof usage.promptTokenCount === 'number') next.promptTokenCount = usage.promptTokenCount;
|
||||
if (typeof usage.candidatesTokenCount === 'number') next.candidatesTokenCount = usage.candidatesTokenCount;
|
||||
if (typeof usage.totalTokenCount === 'number') next.totalTokenCount = usage.totalTokenCount;
|
||||
if (typeof usage.cachedContentTokenCount === 'number') next.cachedContentTokenCount = usage.cachedContentTokenCount;
|
||||
if (typeof usage.thoughtsTokenCount === 'number') next.thoughtsTokenCount = usage.thoughtsTokenCount;
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => {
|
||||
const next: GeminiRecord = {
|
||||
index: candidate.index,
|
||||
finishReason: candidate.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: candidate.parts,
|
||||
},
|
||||
};
|
||||
if (candidate.groundingMetadata) next.groundingMetadata = candidate.groundingMetadata;
|
||||
if (candidate.citationMetadata) next.citationMetadata = candidate.citationMetadata;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const fallback: GeminiRecord = {
|
||||
index: 0,
|
||||
finishReason: state.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: state.parts,
|
||||
},
|
||||
};
|
||||
if (state.groundingMetadata.length > 0) {
|
||||
fallback.groundingMetadata = state.groundingMetadata[0];
|
||||
}
|
||||
if (state.citations.length > 0) {
|
||||
fallback.citationMetadata = state.citations[0];
|
||||
}
|
||||
return [fallback];
|
||||
}
|
||||
|
||||
function extractOrderedCandidateMetadata(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
key: 'groundingMetadata' | 'citationMetadata',
|
||||
): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate[key])
|
||||
.filter((item): item is GeminiRecord => isRecord(item))
|
||||
.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
const fallback = key === 'groundingMetadata' ? state.groundingMetadata : state.citations;
|
||||
return fallback.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
function extractOrderedThoughtSignatures(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
): {
|
||||
all: string[];
|
||||
preferred: string | undefined;
|
||||
} {
|
||||
const ordered = new Set<string>();
|
||||
const preferredThoughts = new Set<string>();
|
||||
|
||||
const candidates = state.candidates.length > 0
|
||||
? state.candidates.slice().sort((left, right) => left.index - right.index)
|
||||
: [{ index: 0, finishReason: state.finishReason, parts: state.parts }];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const part of candidate.parts) {
|
||||
if (!isRecord(part) || typeof part.thoughtSignature !== 'string' || !part.thoughtSignature.trim()) {
|
||||
continue;
|
||||
}
|
||||
ordered.add(part.thoughtSignature);
|
||||
if (part.thought === true) {
|
||||
preferredThoughts.add(part.thoughtSignature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedList = [...ordered];
|
||||
const preferred = [...preferredThoughts][0]
|
||||
?? (orderedList.length === 1 ? orderedList[0] : undefined);
|
||||
|
||||
return {
|
||||
all: orderedList.length > 0 ? orderedList : [...state.thoughtSignatures],
|
||||
preferred,
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePassthrough(metadata: TransformerMetadata): Record<string, unknown> {
|
||||
if (!metadata.passthrough) metadata.passthrough = {};
|
||||
return metadata.passthrough;
|
||||
}
|
||||
|
||||
function extractRequestSemantics(requestPayload: unknown): TransformerMetadata {
|
||||
if (!isRecord(requestPayload)) return {};
|
||||
|
||||
const metadata: TransformerMetadata = {};
|
||||
const passthrough = ensurePassthrough(metadata);
|
||||
if (requestPayload.systemInstruction !== undefined) {
|
||||
passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||
}
|
||||
if (requestPayload.cachedContent !== undefined) {
|
||||
passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||
}
|
||||
if (requestPayload.safetySettings !== undefined) {
|
||||
metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||
}
|
||||
if (requestPayload.toolConfig !== undefined) {
|
||||
passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||
}
|
||||
|
||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||
if (generationConfig) {
|
||||
const preservedKeys = [
|
||||
'stopSequences',
|
||||
'responseModalities',
|
||||
'responseMimeType',
|
||||
'responseSchema',
|
||||
'candidateCount',
|
||||
'maxOutputTokens',
|
||||
'temperature',
|
||||
'topP',
|
||||
'topK',
|
||||
'presencePenalty',
|
||||
'frequencyPenalty',
|
||||
'seed',
|
||||
'responseLogprobs',
|
||||
'logprobs',
|
||||
'thinkingConfig',
|
||||
'imageConfig',
|
||||
];
|
||||
for (const key of preservedKeys) {
|
||||
if (generationConfig[key] !== undefined) {
|
||||
if (key === 'imageConfig') {
|
||||
metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]);
|
||||
} else {
|
||||
passthrough[key] = cloneJsonValue(generationConfig[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(requestPayload.tools)) {
|
||||
const requestTools = requestPayload.tools
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch);
|
||||
if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext);
|
||||
if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution);
|
||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations);
|
||||
return next;
|
||||
})
|
||||
.filter((item) => Object.keys(item).length > 0);
|
||||
|
||||
if (requestTools.length > 0) {
|
||||
passthrough.tools = requestTools;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) {
|
||||
delete metadata.passthrough;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function serializeGeminiGenerateContentAggregateResponse(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): GeminiRecord {
|
||||
const state = ensureAggregateState(payload);
|
||||
const response: GeminiRecord = {
|
||||
responseId: state.responseId || '',
|
||||
modelVersion: state.modelVersion || '',
|
||||
candidates: buildCandidates(state),
|
||||
};
|
||||
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) {
|
||||
response.usageMetadata = usageMetadata;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function extractGeminiGenerateContentTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata {
|
||||
const state = ensureAggregateState(payload);
|
||||
const metadata = extractRequestSemantics(requestPayload);
|
||||
|
||||
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
|
||||
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
|
||||
const thoughtSignatures = extractOrderedThoughtSignatures(state);
|
||||
|
||||
if (citations.length > 0) metadata.citations = citations;
|
||||
if (groundingMetadata.length > 0) metadata.groundingMetadata = groundingMetadata;
|
||||
if (thoughtSignatures.all.length > 0) {
|
||||
if (thoughtSignatures.preferred) {
|
||||
metadata.thoughtSignature = thoughtSignatures.preferred;
|
||||
}
|
||||
metadata.thoughtSignatures = thoughtSignatures.all;
|
||||
}
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) metadata.usageMetadata = usageMetadata;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function extractGeminiGenerateContentResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
||||
return toTransformerMetadataRecord(extractGeminiGenerateContentTransformerMetadata(payload, requestPayload)) ?? {};
|
||||
}
|
||||
|
||||
export const geminiGenerateContentResponseBridge = {
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
extractTransformerMetadata: extractGeminiGenerateContentTransformerMetadata,
|
||||
extractResponseMetadata: extractGeminiGenerateContentResponseMetadata,
|
||||
serializeAggregateResponse: serializeGeminiGenerateContentAggregateResponse,
|
||||
};
|
||||
|
||||
export const geminiGenerateContentOutbound = geminiGenerateContentResponseBridge;
|
||||
|
||||
export {
|
||||
extractGeminiGenerateContentResponseMetadata as extractResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata as extractTransformerMetadata,
|
||||
serializeGeminiGenerateContentAggregateResponse as serializeGeminiAggregateResponse,
|
||||
};
|
||||
@@ -1,253 +1,21 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import { serializeGeminiAggregateResponse } from './outbound.js';
|
||||
|
||||
type ParsedSsePayloads = {
|
||||
events: unknown[];
|
||||
lines: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type GeminiGenerateContentStreamFormat = 'sse' | 'json';
|
||||
|
||||
type ParsedGeminiStreamPayload = {
|
||||
format: GeminiGenerateContentStreamFormat;
|
||||
events: unknown[];
|
||||
lines?: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & {
|
||||
state: GeminiGenerateContentAggregateState;
|
||||
};
|
||||
|
||||
function serializeSsePayload(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
const events: unknown[] = [];
|
||||
const lines: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor));
|
||||
if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break;
|
||||
|
||||
const boundary = cursor + boundaryMatch.index;
|
||||
const block = buffer.slice(cursor, boundary);
|
||||
const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length);
|
||||
cursor = boundary + boundaryMatch[0].length;
|
||||
|
||||
if (!block.trim()) continue;
|
||||
lines.push(rawBlock);
|
||||
|
||||
const data = block
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (!data || data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
events.push(JSON.parse(data));
|
||||
} catch {
|
||||
// Ignore malformed event payloads so aggregation remains tolerant.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
lines,
|
||||
rest: buffer.slice(cursor),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonArrayPayload(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (isRecord(payload)) return [payload];
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseGeminiStreamPayload(
|
||||
payload: unknown,
|
||||
contentType?: string | null,
|
||||
): ParsedGeminiStreamPayload {
|
||||
if (geminiGenerateContentStream.isSseContentType(contentType)) {
|
||||
const parsed = parseSsePayloads(String(payload ?? ''));
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
lines: parsed.lines,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
const parsedJson = JSON.parse(trimmed);
|
||||
return {
|
||||
format: 'json',
|
||||
events: parseJsonArrayPayload(parsedJson),
|
||||
rest: '',
|
||||
};
|
||||
} catch {
|
||||
const parsed = parseSsePayloads(payload);
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
format: 'json',
|
||||
events: [],
|
||||
rest: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
format: 'json',
|
||||
events: parseJsonArrayPayload(payload),
|
||||
rest: '',
|
||||
};
|
||||
}
|
||||
|
||||
function applyParsedPayloadToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
parsed: ParsedGeminiStreamPayload,
|
||||
): AppliedGeminiStreamPayloads {
|
||||
for (const event of parsed.events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
function applyJsonPayloadToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
): GeminiGenerateContentAggregateState {
|
||||
applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(payload, 'application/json'));
|
||||
return state;
|
||||
}
|
||||
|
||||
function applySsePayloadsToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
buffer: string,
|
||||
): AppliedGeminiStreamPayloads {
|
||||
return applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(buffer, 'text/event-stream'));
|
||||
}
|
||||
|
||||
function consumeUpstreamSseBuffer(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
buffer: string,
|
||||
): AppliedGeminiStreamPayloads & { lines: string[] } {
|
||||
const applied = applySsePayloadsToAggregate(state, buffer);
|
||||
return {
|
||||
...applied,
|
||||
lines: applied.lines ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAggregateJsonPayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): unknown {
|
||||
return serializeGeminiAggregateResponse(payload);
|
||||
}
|
||||
|
||||
function serializeAggregateSsePayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): string {
|
||||
return serializeSsePayload(serializeAggregateJsonPayload(payload));
|
||||
}
|
||||
|
||||
function serializeAggregatePayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
format: GeminiGenerateContentStreamFormat = 'json',
|
||||
): unknown {
|
||||
return format === 'sse'
|
||||
? serializeAggregateSsePayload(payload)
|
||||
: serializeAggregateJsonPayload(payload);
|
||||
}
|
||||
|
||||
function serializeUpstreamJsonPayload(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
streamAction = false,
|
||||
): unknown {
|
||||
if (streamAction) {
|
||||
const events = parseJsonArrayPayload(payload);
|
||||
for (const event of events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
applyJsonPayloadToAggregate(state, payload);
|
||||
return serializeAggregateJsonPayload(state);
|
||||
}
|
||||
|
||||
export const geminiGenerateContentStream = {
|
||||
isSseContentType(contentType: string | null | undefined): boolean {
|
||||
return (contentType || '').toLowerCase().includes('text/event-stream');
|
||||
},
|
||||
|
||||
parseJsonArrayPayload,
|
||||
parseGeminiStreamPayload,
|
||||
parseSsePayloads,
|
||||
serializeSsePayload,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
|
||||
createAggregateState(): GeminiGenerateContentAggregateState {
|
||||
return createGeminiGenerateContentAggregateState();
|
||||
},
|
||||
|
||||
applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState {
|
||||
return applyGeminiGenerateContentAggregate(state, payload);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applyParsedPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
geminiGenerateContentStream,
|
||||
parseGeminiStreamPayload,
|
||||
parseJsonArrayPayload,
|
||||
parseSsePayloads,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
serializeSsePayload,
|
||||
consumeUpstreamSseBuffer,
|
||||
};
|
||||
serializeUpstreamJsonPayload,
|
||||
} from './streamBridge.js';
|
||||
export type {
|
||||
AppliedGeminiStreamPayloads,
|
||||
GeminiGenerateContentStreamFormat,
|
||||
ParsedGeminiStreamPayload,
|
||||
ParsedSsePayloads,
|
||||
};
|
||||
} from './streamBridge.js';
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
consumeUpstreamSseBuffer,
|
||||
geminiGenerateContentStream,
|
||||
parseGeminiStreamPayload,
|
||||
serializeAggregateSsePayload,
|
||||
} from './streamBridge.js';
|
||||
|
||||
describe('gemini generate-content stream bridge', () => {
|
||||
it('parses SSE payloads and preserves trailing rest blocks', () => {
|
||||
const parsed = parseGeminiStreamPayload([
|
||||
'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]}}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'data: {"responseId":"partial"',
|
||||
].join('\n'), 'text/event-stream');
|
||||
|
||||
expect(parsed.format).toBe('sse');
|
||||
expect(parsed.events).toHaveLength(1);
|
||||
expect(parsed.rest).toBe('data: {"responseId":"partial"');
|
||||
});
|
||||
|
||||
it('aggregates upstream SSE blocks while preserving raw lines and serializable final output', () => {
|
||||
const state = geminiGenerateContentStream.createAggregateState();
|
||||
const result = consumeUpstreamSseBuffer(state, [
|
||||
'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
expect(result.lines).toHaveLength(2);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.rest).toBe('');
|
||||
expect(serializeAggregateSsePayload(result.state)).toContain('"responseId":"resp-1"');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user