Refactor reasoning handling and metadata management in responses
- Updated chat stream tests to clarify reasoning behavior and ensure correct handling of include lists. - Enhanced responses proxy route to normalize include lists and apply default reasoning includes based on request context. - Improved transformer metadata handling, ensuring proper serialization and deserialization of metadata records. - Added tests for preserving reasoning signatures and managing citations in OpenAI-compatible responses. - Refined the logic for injecting default includes in responses and ensured compatibility with codex surface defaults.
This commit is contained in:
@@ -1826,7 +1826,7 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(targetUrl).toContain('/v1/responses');
|
||||
});
|
||||
|
||||
it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without an explicit include list', async () => {
|
||||
it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
|
||||
@@ -1870,6 +1870,96 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/responses');
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.include).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps generic claude-family /v1/responses requests on the default messages-first order when codex headers are absent', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'msg_default_messages_first',
|
||||
type: 'message',
|
||||
model: 'upstream-gpt',
|
||||
content: [{ type: 'text', text: 'messages endpoint selected by default' }],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/messages');
|
||||
});
|
||||
|
||||
it('defaults encrypted reasoning include and prefers native /v1/responses for claude-family codex-surface requests even without reasoning config', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_reasoning_default',
|
||||
object: 'response',
|
||||
model: 'upstream-gpt',
|
||||
output_text: 'hello from responses upstream',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_reasoning_default',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'hello from responses upstream' }],
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: {
|
||||
'openai-beta': 'responses-2025-03-11',
|
||||
'x-stainless-lang': 'typescript',
|
||||
originator: 'codex_cli_rs',
|
||||
},
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
@@ -1878,6 +1968,100 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']);
|
||||
});
|
||||
|
||||
it('keeps explicit empty include on claude-family codex-surface responses requests and stays on the default messages-first order', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'msg_explicit_empty_include',
|
||||
type: 'message',
|
||||
model: 'upstream-gpt',
|
||||
content: [{ type: 'text', text: 'messages endpoint selected because include stayed empty' }],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: {
|
||||
'openai-beta': 'responses-2025-03-11',
|
||||
'x-stainless-lang': 'typescript',
|
||||
originator: 'codex_cli_rs',
|
||||
},
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
input: 'hello',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
include: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/messages');
|
||||
});
|
||||
|
||||
it('keeps explicit custom include on claude-family codex-surface responses requests and stays on the default messages-first order', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'msg_explicit_custom_include',
|
||||
type: 'message',
|
||||
model: 'upstream-gpt',
|
||||
content: [{ type: 'text', text: 'messages endpoint selected because custom include stayed explicit' }],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: {
|
||||
'openai-beta': 'responses-2025-03-11',
|
||||
'x-stainless-lang': 'typescript',
|
||||
originator: 'codex_cli_rs',
|
||||
},
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
input: 'hello',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
include: ['message.input_image.image_url'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/messages');
|
||||
});
|
||||
|
||||
it('forces anyrouter platform to prefer /v1/messages even when catalog says openai', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
|
||||
@@ -26,7 +26,6 @@ import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
hasNonImageFileInputInOpenAiBody,
|
||||
resolveOpenAiBodyInputFiles,
|
||||
resolveResponsesBodyInputFiles,
|
||||
} from '../../services/proxyInputFileResolver.js';
|
||||
|
||||
@@ -36,6 +35,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function normalizeIncludeList(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function hasExplicitInclude(body: Record<string, unknown>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(body, 'include');
|
||||
}
|
||||
|
||||
function hasResponsesReasoningRequest(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
|
||||
@@ -46,17 +60,79 @@ function hasResponsesReasoningRequest(value: unknown): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
function carriesResponsesReasoningContinuity(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => carriesResponsesReasoningContinuity(item));
|
||||
}
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
const type = typeof value.type === 'string' ? value.type.trim().toLowerCase() : '';
|
||||
if (type === 'reasoning') {
|
||||
if (typeof value.encrypted_content === 'string' && value.encrypted_content.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value.summary) && value.summary.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value.reasoning_signature === 'string' && value.reasoning_signature.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return carriesResponsesReasoningContinuity(value.input)
|
||||
|| carriesResponsesReasoningContinuity(value.content);
|
||||
}
|
||||
|
||||
function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||
if (!headers) return false;
|
||||
|
||||
const normalizeHeaderValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.find((item) => item.length > 0) || '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
let sawOpenAiBeta = false;
|
||||
let sawStainless = false;
|
||||
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
const value = normalizeHeaderValue(rawValue);
|
||||
if (!key || !value) continue;
|
||||
|
||||
if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'openai-beta') {
|
||||
sawOpenAiBeta = true;
|
||||
}
|
||||
if (key.startsWith('x-stainless-')) {
|
||||
sawStainless = true;
|
||||
}
|
||||
}
|
||||
|
||||
return sawOpenAiBeta || sawStainless;
|
||||
}
|
||||
|
||||
function wantsNativeResponsesReasoning(body: unknown): boolean {
|
||||
if (!isRecord(body)) return false;
|
||||
|
||||
const include = Array.isArray(body.include)
|
||||
? body.include
|
||||
: (typeof body.include === 'string' ? [body.include] : []);
|
||||
|
||||
return include.some((item) => (
|
||||
typeof item === 'string'
|
||||
&& item.trim().toLowerCase() === 'reasoning.encrypted_content'
|
||||
)) || hasResponsesReasoningRequest(body.reasoning);
|
||||
const include = normalizeIncludeList(body.include);
|
||||
if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) {
|
||||
return true;
|
||||
}
|
||||
if (carriesResponsesReasoningContinuity(body.input)) {
|
||||
return true;
|
||||
}
|
||||
if (hasExplicitInclude(body)) {
|
||||
return false;
|
||||
}
|
||||
return hasResponsesReasoningRequest(body.reasoning);
|
||||
}
|
||||
|
||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||
@@ -103,14 +179,19 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(body, modelName, isStream);
|
||||
const owner = getProxyResourceOwner(request);
|
||||
let resolvedOpenAiBody = openAiBody;
|
||||
let resolvedResponsesBody = body;
|
||||
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
|
||||
request.headers as Record<string, unknown>,
|
||||
);
|
||||
let normalizedResponsesBody = openAiResponsesTransformer.inbound.sanitizeProxyBody(
|
||||
body,
|
||||
modelName,
|
||||
isStream,
|
||||
{ defaultEncryptedReasoningInclude },
|
||||
);
|
||||
if (owner) {
|
||||
try {
|
||||
resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(openAiBody, owner);
|
||||
resolvedResponsesBody = await resolveResponsesBodyInputFiles(body, owner);
|
||||
normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner);
|
||||
} catch (error) {
|
||||
if (error instanceof ProxyInputFileResolutionError) {
|
||||
return reply.code(error.statusCode).send(error.payload);
|
||||
@@ -118,8 +199,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
|
||||
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(resolvedResponsesBody);
|
||||
const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(
|
||||
normalizedResponsesBody,
|
||||
modelName,
|
||||
isStream,
|
||||
{ defaultEncryptedReasoningInclude },
|
||||
);
|
||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
|
||||
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
|
||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
@@ -152,9 +239,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
tokenValue: selected.tokenValue,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
openaiBody: openAiBody,
|
||||
downstreamFormat: 'responses',
|
||||
responsesOriginalBody: resolvedResponsesBody,
|
||||
responsesOriginalBody: normalizedResponsesBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
});
|
||||
const upstreamPath = (
|
||||
|
||||
Reference in New Issue
Block a user