forked from boos/BoosAPI
Preserve the active worktree snapshot on dev for follow-on integration
The root checkout already contained a large in-progress proxy, transformer, and settings refactor. This commit captures that exact work on a dedicated local dev branch without disturbing the original dirty worktree, so the changes can be continued or split from dev instead of living only as uncommitted state. Constraint: The source checkout was already dirty on another branch, so the move had to preserve that worktree while excluding local .idea and .omx residue from the commit Rejected: Switch the dirty root checkout directly to dev | risked disrupting the original branch and in-progress state Confidence: medium Scope-risk: broad Reversibility: clean Directive: Treat this as a transfer snapshot; fix the failing oauth/accountTokens/normalized/docs test regressions before merge or publication Tested: git diff --check; npm run typecheck; npm run repo:drift-check Not-tested: Clean test pass; npm test currently fails in oauth routes, accountTokens sync, shared normalized helpers, gemini route expectations, architecture-boundaries, and docs vitepress config
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureCompactResponsesJsonAcceptHeader,
|
||||
sanitizeCompactResponsesRequestBody,
|
||||
shouldForceResponsesUpstreamStream,
|
||||
} from './responsesCompact.js';
|
||||
|
||||
describe('sanitizeCompactResponsesRequestBody', () => {
|
||||
it('removes stream fields and store from codex compact requests', () => {
|
||||
expect(sanitizeCompactResponsesRequestBody({
|
||||
stream: true,
|
||||
stream_options: { include_obfuscation: true },
|
||||
store: false,
|
||||
input: 'hello',
|
||||
}, {
|
||||
sitePlatform: 'codex',
|
||||
})).toEqual({
|
||||
input: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes store from sub2api compact requests', () => {
|
||||
expect(sanitizeCompactResponsesRequestBody({
|
||||
stream: true,
|
||||
stream_options: { include_obfuscation: true },
|
||||
store: false,
|
||||
input: 'hello',
|
||||
}, {
|
||||
sitePlatform: 'sub2api',
|
||||
})).toEqual({
|
||||
input: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps store for unrelated compact platforms while still removing stream fields', () => {
|
||||
expect(sanitizeCompactResponsesRequestBody({
|
||||
stream: true,
|
||||
stream_options: { include_obfuscation: true },
|
||||
store: false,
|
||||
input: 'hello',
|
||||
}, {
|
||||
sitePlatform: 'openai',
|
||||
})).toEqual({
|
||||
store: false,
|
||||
input: 'hello',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldForceResponsesUpstreamStream', () => {
|
||||
it('forces non-compact responses streaming for codex and sub2api', () => {
|
||||
expect(shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: 'codex',
|
||||
isCompactRequest: false,
|
||||
})).toBe(true);
|
||||
expect(shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: 'sub2api',
|
||||
isCompactRequest: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('does not force compact or unrelated platforms', () => {
|
||||
expect(shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: 'codex',
|
||||
isCompactRequest: true,
|
||||
})).toBe(false);
|
||||
expect(shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: 'openai',
|
||||
isCompactRequest: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCompactResponsesJsonAcceptHeader', () => {
|
||||
it('adds application/json accept for codex and sub2api compact requests when missing', () => {
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({}, {
|
||||
sitePlatform: 'codex',
|
||||
})).toEqual({
|
||||
accept: 'application/json',
|
||||
});
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({}, {
|
||||
sitePlatform: 'sub2api',
|
||||
})).toEqual({
|
||||
accept: 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing accept values and unrelated platforms', () => {
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({
|
||||
Accept: 'application/json',
|
||||
}, {
|
||||
sitePlatform: 'codex',
|
||||
})).toEqual({
|
||||
Accept: 'application/json',
|
||||
});
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({}, {
|
||||
sitePlatform: 'openai',
|
||||
})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -2,35 +2,81 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function shouldStripCompactResponsesStore(sitePlatform?: string): boolean {
|
||||
const normalized = asTrimmedString(sitePlatform).toLowerCase();
|
||||
return normalized === 'codex' || normalized === 'sub2api';
|
||||
}
|
||||
|
||||
function shouldForceCompactResponsesJsonAccept(sitePlatform?: string): boolean {
|
||||
return shouldStripCompactResponsesStore(sitePlatform);
|
||||
}
|
||||
|
||||
export function shouldForceResponsesUpstreamStream(input: {
|
||||
sitePlatform?: string;
|
||||
isCompactRequest?: boolean;
|
||||
}): boolean {
|
||||
if (input.isCompactRequest) return false;
|
||||
const sitePlatform = asTrimmedString(input.sitePlatform).toLowerCase();
|
||||
return sitePlatform === 'codex' || sitePlatform === 'sub2api';
|
||||
}
|
||||
|
||||
export function sanitizeCompactResponsesRequestBody(
|
||||
body: Record<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 existingAccept = asTrimmedString(headers.accept) || asTrimmedString(headers.Accept);
|
||||
if (existingAccept) return headers;
|
||||
return {
|
||||
...headers,
|
||||
accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldFallbackCompactResponsesToResponses(input: {
|
||||
status?: number;
|
||||
rawErrText?: string;
|
||||
requestPath?: string;
|
||||
}): boolean {
|
||||
const status = Number.isFinite(Number(input.status)) ? Number(input.status) : 0;
|
||||
const compact = asTrimmedString(input.rawErrText).toLowerCase();
|
||||
const hasCompactHint = (
|
||||
const requestPath = asTrimmedString(input.requestPath).toLowerCase();
|
||||
const hasRawCompactHint = (
|
||||
compact.includes('/responses/compact')
|
||||
|| compact.includes('responses/compact')
|
||||
|| compact.includes('compact endpoint')
|
||||
|| /(^|[^a-z])compact([^a-z]|$)/.test(compact)
|
||||
);
|
||||
const hasCompactRequestPathHint = (
|
||||
requestPath.endsWith('/responses/compact')
|
||||
|| requestPath.endsWith('/v1/responses/compact')
|
||||
);
|
||||
const isInvalidCompactUrl = compact.includes('invalid url') && hasRawCompactHint;
|
||||
|
||||
if (status === 404 || status === 405 || status === 501) return true;
|
||||
|
||||
return (
|
||||
compact.includes("unknown parameter: 'stream'")
|
||||
|| compact.includes('invalid url')
|
||||
(compact.includes("unknown parameter: 'stream'") && (hasRawCompactHint || hasCompactRequestPathHint))
|
||||
|| isInvalidCompactUrl
|
||||
|| (
|
||||
hasCompactHint
|
||||
hasRawCompactHint
|
||||
&& (
|
||||
compact.includes('not supported')
|
||||
|| compact.includes('unsupported')
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
const detected = detectCodexOfficialClientAppFromHeaders(headers);
|
||||
return detected
|
||||
? {
|
||||
clientAppId: detected.clientAppId,
|
||||
clientAppName: detected.clientAppName,
|
||||
}
|
||||
: 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> {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { zstdCompressSync } from 'node:zlib';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Response } from 'undici';
|
||||
|
||||
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
|
||||
|
||||
describe('collectResponsesFinalPayloadFromSse', () => {
|
||||
it('treats event:error payloads as upstream failures', async () => {
|
||||
const upstream = {
|
||||
async text() {
|
||||
return [
|
||||
'event: error',
|
||||
'data: {"error":{"message":"quota exceeded"},"type":"error"}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.rejects
|
||||
.toThrow('quota exceeded');
|
||||
});
|
||||
|
||||
it('prefers aggregated stream content when response.completed only carries an empty output array', async () => {
|
||||
const upstream = {
|
||||
async text() {
|
||||
return [
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_empty_completed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||
'',
|
||||
'event: response.output_item.added',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_empty_completed","type":"message","role":"assistant","status":"in_progress","content":[]}}',
|
||||
'',
|
||||
'event: response.output_text.delta',
|
||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_empty_completed","delta":"pong"}',
|
||||
'',
|
||||
'event: response.completed',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_empty_completed","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
payload: {
|
||||
id: 'resp_empty_completed',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_empty_completed',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'pong',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
output_text: 'pong',
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 1,
|
||||
total_tokens: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed responses SSE before aggregating the final payload', async () => {
|
||||
const upstream = new Response(zstdCompressSync(Buffer.from([
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_zstd_completed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||
'',
|
||||
'event: response.output_item.added',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_zstd_completed","type":"message","role":"assistant","status":"in_progress","content":[]}}',
|
||||
'',
|
||||
'event: response.output_text.delta',
|
||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_zstd_completed","delta":"你好,来自 zstd 聚合 SSE"}',
|
||||
'',
|
||||
'event: response.completed',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_zstd_completed","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n'))), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
payload: {
|
||||
id: 'resp_zstd_completed',
|
||||
status: 'completed',
|
||||
output_text: '你好,来自 zstd 聚合 SSE',
|
||||
},
|
||||
rawText: expect.stringContaining('你好,来自 zstd 聚合 SSE'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns response.incomplete payloads instead of treating them as upstream failures', async () => {
|
||||
const upstream = {
|
||||
async text() {
|
||||
return [
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_incomplete_terminal","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||
'',
|
||||
'event: response.incomplete',
|
||||
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_terminal","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial answer"}]}],"output_text":"partial answer","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
payload: {
|
||||
id: 'resp_incomplete_terminal',
|
||||
status: 'incomplete',
|
||||
output_text: 'partial answer',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns response.incomplete terminal payloads instead of throwing', async () => {
|
||||
const upstream = {
|
||||
async text() {
|
||||
return [
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_incomplete_1","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||
'',
|
||||
'event: response.output_text.delta',
|
||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_incomplete_1","delta":"partial"}',
|
||||
'',
|
||||
'event: response.incomplete',
|
||||
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_1","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
payload: {
|
||||
id: 'resp_incomplete_1',
|
||||
status: 'incomplete',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
output_text: 'partial',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats response.incomplete as terminal even when only response.output carries the visible text', async () => {
|
||||
const upstream = {
|
||||
async text() {
|
||||
return [
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_incomplete_output_only","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||
'',
|
||||
'event: response.incomplete',
|
||||
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_output_only","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_output_only","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial from output"}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
payload: {
|
||||
id: 'resp_incomplete_output_only',
|
||||
status: 'incomplete',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_incomplete_output_only',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'incomplete',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'partial from output',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
output_text: 'partial from output',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves incomplete item status when response.incomplete needs aggregate output repair', async () => {
|
||||
const upstream = {
|
||||
async text() {
|
||||
return [
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_incomplete_repair","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||
'',
|
||||
'event: response.output_item.added',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_incomplete_repair","type":"message","role":"assistant","status":"in_progress","content":[]}}',
|
||||
'',
|
||||
'event: response.output_text.delta',
|
||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_incomplete_repair","delta":"partial repair"}',
|
||||
'',
|
||||
'event: response.incomplete',
|
||||
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_repair","model":"gpt-5.4","status":"incomplete","output":[],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
payload: {
|
||||
id: 'resp_incomplete_repair',
|
||||
status: 'incomplete',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_incomplete_repair',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'incomplete',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'partial repair',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
output_text: 'partial repair',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { readRuntimeResponseText } from '../executors/types.js';
|
||||
|
||||
type ResponsesTerminalStatus = 'completed' | 'incomplete';
|
||||
|
||||
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 parseResponsesSsePayload(data: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getResponsesFailureMessage(payload: Record<string, unknown>): string {
|
||||
if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) {
|
||||
return payload.error.message.trim();
|
||||
}
|
||||
if (typeof payload.message === 'string' && payload.message.trim()) {
|
||||
return payload.message.trim();
|
||||
}
|
||||
return 'upstream stream failed';
|
||||
}
|
||||
|
||||
function hasMeaningfulMessageContent(content: unknown): boolean {
|
||||
if (!Array.isArray(content)) return false;
|
||||
return content.some((part) => {
|
||||
if (!isRecord(part)) return false;
|
||||
const partType = typeof part.type === 'string' ? part.type.trim().toLowerCase() : '';
|
||||
if (partType === 'output_text' || partType === 'text') {
|
||||
return typeof part.text === 'string' && part.text.length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function hasMeaningfulResponsesOutput(output: unknown): boolean {
|
||||
if (!Array.isArray(output)) return false;
|
||||
return output.some((item) => {
|
||||
if (!isRecord(item)) return false;
|
||||
const itemType = typeof item.type === 'string' ? item.type.trim().toLowerCase() : '';
|
||||
if (itemType === 'message') {
|
||||
return hasMeaningfulMessageContent(item.content);
|
||||
}
|
||||
if (itemType === 'reasoning') {
|
||||
return (
|
||||
(Array.isArray(item.summary) && item.summary.length > 0)
|
||||
|| (typeof item.encrypted_content === 'string' && item.encrypted_content.trim().length > 0)
|
||||
);
|
||||
}
|
||||
return itemType.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boolean {
|
||||
return (
|
||||
payload.object === 'response.compaction'
|
||||
|| Array.isArray(payload.output)
|
||||
|| Object.prototype.hasOwnProperty.call(payload, 'output_text')
|
||||
);
|
||||
}
|
||||
|
||||
function hasMeaningfulFinalResponsesPayload(payload: Record<string, unknown>): boolean {
|
||||
if (payload.object === 'response.compaction') {
|
||||
return Array.isArray(payload.output) && payload.output.length > 0;
|
||||
}
|
||||
if (typeof payload.output_text === 'string' && payload.output_text.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return hasMeaningfulResponsesOutput(payload.output);
|
||||
}
|
||||
|
||||
function collectResponsesOutputText(payload: Record<string, unknown>): string {
|
||||
const output = Array.isArray(payload.output) ? payload.output : [];
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const item of output) {
|
||||
if (!isRecord(item)) continue;
|
||||
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) continue;
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
const text = typeof part.text === 'string' ? part.text : '';
|
||||
if ((partType === 'output_text' || partType === 'text') && text) {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function rememberStreamResponseEnvelope(
|
||||
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
const responsePayload = isRecord(payload.response) ? payload.response : payload;
|
||||
if (typeof responsePayload.id === 'string' && responsePayload.id.trim().length > 0) {
|
||||
streamContext.id = responsePayload.id;
|
||||
}
|
||||
if (typeof responsePayload.model === 'string' && responsePayload.model.trim().length > 0) {
|
||||
streamContext.model = responsePayload.model;
|
||||
}
|
||||
const createdAt = (
|
||||
typeof responsePayload.created_at === 'number' && Number.isFinite(responsePayload.created_at)
|
||||
? responsePayload.created_at
|
||||
: (typeof responsePayload.created === 'number' && Number.isFinite(responsePayload.created)
|
||||
? responsePayload.created
|
||||
: null)
|
||||
);
|
||||
if (createdAt !== null) {
|
||||
streamContext.created = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureResponseId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||
}
|
||||
|
||||
function buildUsagePayload(usage: ReturnType<typeof parseProxyUsage>): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
};
|
||||
const inputDetails: Record<string, unknown> = {};
|
||||
if ((usage.cacheReadTokens || 0) > 0) inputDetails.cached_tokens = usage.cacheReadTokens;
|
||||
if ((usage.cacheCreationTokens || 0) > 0) inputDetails.cache_creation_tokens = usage.cacheCreationTokens;
|
||||
if (Object.keys(inputDetails).length > 0) payload.input_tokens_details = inputDetails;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function cloneAggregateOutputItem(
|
||||
item: unknown,
|
||||
terminalStatus: ResponsesTerminalStatus,
|
||||
): Record<string, unknown> | null {
|
||||
if (!isRecord(item)) return null;
|
||||
const next = structuredClone(item);
|
||||
const currentStatus = asTrimmedString(next.status).toLowerCase();
|
||||
next.status = currentStatus && currentStatus !== 'in_progress'
|
||||
? currentStatus
|
||||
: terminalStatus;
|
||||
return next;
|
||||
}
|
||||
|
||||
function materializeTerminalPayloadFromAggregate(
|
||||
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
|
||||
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
||||
usage: ReturnType<typeof parseProxyUsage>,
|
||||
terminalStatus: ResponsesTerminalStatus,
|
||||
): Record<string, unknown> {
|
||||
const output = aggregateState.outputItems
|
||||
.map((item) => cloneAggregateOutputItem(item, terminalStatus))
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
const usagePayload = buildUsagePayload(usage);
|
||||
const usageWithExtras = Object.keys(aggregateState.usageExtras).length > 0
|
||||
? { ...usagePayload, ...structuredClone(aggregateState.usageExtras) }
|
||||
: usagePayload;
|
||||
|
||||
return {
|
||||
id: ensureResponseId(
|
||||
asTrimmedString(aggregateState.responseId)
|
||||
|| asTrimmedString(streamContext.id)
|
||||
|| asTrimmedString(aggregateState.modelName),
|
||||
),
|
||||
object: 'response',
|
||||
created_at: (
|
||||
typeof aggregateState.createdAt === 'number' && Number.isFinite(aggregateState.createdAt)
|
||||
? aggregateState.createdAt
|
||||
: streamContext.created
|
||||
) || Math.floor(Date.now() / 1000),
|
||||
status: terminalStatus,
|
||||
model: asTrimmedString(streamContext.model) || asTrimmedString(aggregateState.modelName),
|
||||
output,
|
||||
output_text: collectResponsesOutputText({ output }),
|
||||
usage: usageWithExtras,
|
||||
};
|
||||
}
|
||||
|
||||
function enrichTerminalPayload(
|
||||
payload: Record<string, unknown>,
|
||||
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
|
||||
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
||||
usage: ReturnType<typeof parseProxyUsage>,
|
||||
terminalStatus: ResponsesTerminalStatus,
|
||||
): Record<string, unknown> {
|
||||
const next = structuredClone(payload);
|
||||
const materialized = materializeTerminalPayloadFromAggregate(
|
||||
aggregateState,
|
||||
streamContext,
|
||||
usage,
|
||||
terminalStatus,
|
||||
);
|
||||
|
||||
if (!hasMeaningfulResponsesOutput(next.output) && materialized && hasMeaningfulResponsesOutput(materialized.output)) {
|
||||
next.output = materialized.output;
|
||||
}
|
||||
|
||||
const currentOutputText = typeof next.output_text === 'string' ? next.output_text : '';
|
||||
if (!currentOutputText) {
|
||||
const derivedOutputText = collectResponsesOutputText(next)
|
||||
|| (materialized && typeof materialized.output_text === 'string' ? materialized.output_text : '');
|
||||
if (derivedOutputText) {
|
||||
next.output_text = derivedOutputText;
|
||||
}
|
||||
}
|
||||
|
||||
if (materialized && next.usage === undefined && materialized.usage !== undefined) {
|
||||
next.usage = materialized.usage;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeMissingResponsesTerminalFields(
|
||||
payload: Record<string, unknown>,
|
||||
fallbackPayload: Record<string, unknown> | null,
|
||||
): Record<string, unknown> {
|
||||
if (!fallbackPayload) return payload;
|
||||
const merged = { ...payload };
|
||||
if (merged.output === undefined && fallbackPayload.output !== undefined) {
|
||||
merged.output = fallbackPayload.output;
|
||||
}
|
||||
if (
|
||||
(typeof merged.output_text !== 'string' || merged.output_text.length === 0)
|
||||
&& typeof fallbackPayload.output_text === 'string'
|
||||
&& fallbackPayload.output_text.length > 0
|
||||
) {
|
||||
merged.output_text = fallbackPayload.output_text;
|
||||
}
|
||||
if (merged.object === undefined && fallbackPayload.object !== undefined) {
|
||||
merged.object = fallbackPayload.object;
|
||||
}
|
||||
if (merged.created_at === undefined && fallbackPayload.created_at !== undefined) {
|
||||
merged.created_at = fallbackPayload.created_at;
|
||||
}
|
||||
if (merged.usage === undefined && fallbackPayload.usage !== undefined) {
|
||||
merged.usage = fallbackPayload.usage;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function looksLikeResponsesSseText(rawText: string): boolean {
|
||||
const { events, rest } = openAiResponsesTransformer.pullSseEvents(rawText);
|
||||
if (events.length === 0 || rest.trim().length > 0) return false;
|
||||
return events.some((event) => {
|
||||
if (event.data === '[DONE]') return true;
|
||||
if (event.event === 'error' || event.event.startsWith('response.')) return true;
|
||||
const payload = parseResponsesSsePayload(event.data);
|
||||
const payloadType = typeof payload?.type === 'string' ? payload.type : '';
|
||||
return payloadType === 'error' || payloadType.startsWith('response.');
|
||||
});
|
||||
}
|
||||
|
||||
export function createSingleChunkStreamReader(rawText: string): {
|
||||
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
||||
cancel(reason?: unknown): Promise<unknown>;
|
||||
releaseLock(): void;
|
||||
} {
|
||||
const chunk = Buffer.from(rawText, 'utf8');
|
||||
let done = false;
|
||||
return {
|
||||
async read() {
|
||||
if (done) return { done: true };
|
||||
done = true;
|
||||
return { done: false, value: chunk };
|
||||
},
|
||||
async cancel() {
|
||||
done = true;
|
||||
return undefined;
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
}
|
||||
|
||||
export function collectResponsesFinalPayloadFromSseText(
|
||||
rawText: string,
|
||||
modelName: string,
|
||||
): { payload: Record<string, unknown>; rawText: string } {
|
||||
const { events } = openAiResponsesTransformer.pullSseEvents(rawText);
|
||||
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
|
||||
const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName);
|
||||
let usage = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null as boolean | null,
|
||||
};
|
||||
let completedPayload: Record<string, unknown> | null = null;
|
||||
let terminalStatus: ResponsesTerminalStatus = 'completed';
|
||||
|
||||
const captureCompletedPayloadFromEvent = (
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => {
|
||||
if (completedPayload) return;
|
||||
if (eventType === 'response.failed' || eventType === 'error') {
|
||||
throw new Error(getResponsesFailureMessage(payload));
|
||||
}
|
||||
if (eventType !== 'response.completed' && eventType !== 'response.incomplete') {
|
||||
return;
|
||||
}
|
||||
terminalStatus = eventType === 'response.incomplete' ? 'incomplete' : 'completed';
|
||||
if (isRecord(payload.response) && hasCompleteFinalResponsesPayload(payload.response)) {
|
||||
completedPayload = payload.response;
|
||||
return;
|
||||
}
|
||||
if (hasCompleteFinalResponsesPayload(payload)) {
|
||||
completedPayload = payload;
|
||||
}
|
||||
};
|
||||
|
||||
const captureCompletedPayloadFromLines = (lines: string[]) => {
|
||||
if (completedPayload) return;
|
||||
const parsed = openAiResponsesTransformer.pullSseEvents(lines.join(''));
|
||||
for (const event of parsed.events) {
|
||||
if (event.data === '[DONE]') continue;
|
||||
const payload = parseResponsesSsePayload(event.data);
|
||||
if (!payload) continue;
|
||||
const payloadType = typeof payload.type === 'string' ? payload.type : '';
|
||||
captureCompletedPayloadFromEvent(payloadType || event.event, payload);
|
||||
if (completedPayload) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
if (event.data === '[DONE]') continue;
|
||||
const payload = parseResponsesSsePayload(event.data);
|
||||
if (!payload) continue;
|
||||
|
||||
const payloadType = typeof payload.type === 'string' ? payload.type : '';
|
||||
const eventType = payloadType || event.event;
|
||||
rememberStreamResponseEnvelope(streamContext, payload);
|
||||
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
|
||||
captureCompletedPayloadFromEvent(eventType, payload);
|
||||
if (completedPayload) {
|
||||
continue;
|
||||
}
|
||||
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(
|
||||
payload,
|
||||
streamContext,
|
||||
modelName,
|
||||
);
|
||||
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({
|
||||
state: aggregateState,
|
||||
streamContext,
|
||||
event: normalizedEvent,
|
||||
usage,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
completedPayload
|
||||
&& !hasMeaningfulFinalResponsesPayload(completedPayload)
|
||||
&& hasMeaningfulResponsesOutput(aggregateState.outputItems)
|
||||
) {
|
||||
completedPayload = mergeMissingResponsesTerminalFields(
|
||||
completedPayload,
|
||||
materializeTerminalPayloadFromAggregate(
|
||||
aggregateState,
|
||||
streamContext,
|
||||
usage,
|
||||
terminalStatus,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (completedPayload) {
|
||||
completedPayload = mergeMissingResponsesTerminalFields(
|
||||
completedPayload,
|
||||
materializeTerminalPayloadFromAggregate(
|
||||
aggregateState,
|
||||
streamContext,
|
||||
usage,
|
||||
terminalStatus,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!completedPayload) {
|
||||
const materialized = materializeTerminalPayloadFromAggregate(
|
||||
aggregateState,
|
||||
streamContext,
|
||||
usage,
|
||||
terminalStatus,
|
||||
);
|
||||
if (materialized) {
|
||||
completedPayload = materialized;
|
||||
}
|
||||
}
|
||||
|
||||
if (completedPayload) {
|
||||
return {
|
||||
payload: enrichTerminalPayload(
|
||||
completedPayload,
|
||||
aggregateState,
|
||||
streamContext,
|
||||
usage,
|
||||
terminalStatus,
|
||||
),
|
||||
rawText,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('stream disconnected before terminal responses event');
|
||||
}
|
||||
|
||||
export async function collectResponsesFinalPayloadFromSse(
|
||||
upstream: { text(): Promise<string>; headers?: { get(name: string): string | null } },
|
||||
modelName: string,
|
||||
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
|
||||
const rawText = typeof upstream.headers?.get === 'function'
|
||||
? await readRuntimeResponseText(upstream as Parameters<typeof readRuntimeResponseText>[0])
|
||||
: await upstream.text();
|
||||
return collectResponsesFinalPayloadFromSseText(rawText, modelName);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -104,7 +107,12 @@ 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') {
|
||||
if (
|
||||
normalizedKey === 'session_id'
|
||||
|| normalizedKey === 'session-id'
|
||||
|| normalizedKey === 'conversation_id'
|
||||
|| normalizedKey === 'conversation-id'
|
||||
) {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
}
|
||||
@@ -417,7 +425,24 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl
|
||||
|| carriesResponsesFileUrlInput(normalizedResponsesBody.input);
|
||||
const endpointCandidates: UpstreamEndpoint[] = isCompactRequest
|
||||
? ['responses']
|
||||
? await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
},
|
||||
modelName,
|
||||
'responses',
|
||||
requestedModel,
|
||||
{
|
||||
hasNonImageFileInput,
|
||||
conversationFileSummary,
|
||||
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
||||
},
|
||||
{
|
||||
requestKind: 'responses-compact',
|
||||
requiresNativeResponsesFileUrl,
|
||||
},
|
||||
)
|
||||
: await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
@@ -431,10 +456,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
conversationFileSummary,
|
||||
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
||||
},
|
||||
{
|
||||
requiresNativeResponsesFileUrl,
|
||||
},
|
||||
);
|
||||
if (endpointCandidates.length === 0) {
|
||||
endpointCandidates.push('responses', 'chat', 'messages');
|
||||
}
|
||||
const endpointRuntimeContext = {
|
||||
siteId: selected.site.id,
|
||||
modelName,
|
||||
@@ -467,9 +492,12 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
})
|
||||
);
|
||||
const executeEndpointResultForSiteApiBaseUrl = async (siteApiBaseUrl: string) => {
|
||||
const forceCodexUpstreamStream = isCodexSite && !isCompactRequest;
|
||||
const forceResponsesUpstreamStream = shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: selected.site.platform,
|
||||
isCompactRequest,
|
||||
});
|
||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||
const upstreamStream = isStream || (forceCodexUpstreamStream && endpoint === 'responses');
|
||||
const upstreamStream = isStream || (forceResponsesUpstreamStream && endpoint === 'responses');
|
||||
const responsesOriginalBody = (
|
||||
endpoint === 'responses'
|
||||
&& isCodexSite
|
||||
@@ -498,6 +526,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
responsesOriginalBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildProviderHeaders(),
|
||||
codexExplicitSessionId: codexSessionId || null,
|
||||
});
|
||||
const upstreamPath = (
|
||||
isCompactRequest && endpoint === 'responses'
|
||||
@@ -506,13 +535,22 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
);
|
||||
const requestBody = (
|
||||
isCompactRequest && endpoint === 'responses'
|
||||
? sanitizeCompactResponsesRequestBody(endpointRequest.body as Record<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 +574,9 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
);
|
||||
};
|
||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||
isStream: isStream || forceCodexUpstreamStream,
|
||||
isStream: isStream || forceResponsesUpstreamStream,
|
||||
requiresNativeResponsesFileUrl,
|
||||
sitePlatform: selected.site.platform,
|
||||
dispatchRequest,
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
@@ -587,19 +626,44 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
ctx.rawErrText = await readRuntimeResponseText(recoveredResponse).catch(() => 'unknown error');
|
||||
}
|
||||
}
|
||||
const compactFallbackEnabled = config.responsesCompactFallbackToResponsesEnabled || !isCodexSite;
|
||||
if (
|
||||
isCompactRequest
|
||||
&& config.responsesCompactFallbackToResponsesEnabled
|
||||
&& compactFallbackEnabled
|
||||
&& ctx.request.endpoint === 'responses'
|
||||
&& ctx.request.path.endsWith('/responses/compact')
|
||||
&& shouldFallbackCompactResponsesToResponses({
|
||||
status: ctx.response.status,
|
||||
rawErrText: ctx.rawErrText,
|
||||
requestPath: ctx.request.path,
|
||||
})
|
||||
) {
|
||||
const normalizedSitePlatform = String(selected.site.platform || '').trim().toLowerCase();
|
||||
const recoveredUpstreamStream = shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: selected.site.platform,
|
||||
isCompactRequest: false,
|
||||
});
|
||||
const recoveredHeaders = { ...ctx.request.headers } as Record<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 +748,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
},
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
onDowngrade: async (ctx) => {
|
||||
promoteRequiredEndpointCandidateAfterProtocolError(endpointCandidates, {
|
||||
currentEndpoint: ctx.request.endpoint,
|
||||
upstreamErrorText: ctx.rawErrText,
|
||||
});
|
||||
await safeUpdateSurfaceProxyDebugAttempt(debugTrace, debugAttemptBase + ctx.endpointIndex, {
|
||||
downgradeDecision: true,
|
||||
downgradeReason: ctx.errText,
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user