forked from boos/BoosAPI
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');
|
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({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
|
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(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
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']);
|
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 () => {
|
it('forces anyrouter platform to prefer /v1/messages even when catalog says openai', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { getProxyResourceOwner } from '../../middleware/auth.js';
|
|||||||
import {
|
import {
|
||||||
ProxyInputFileResolutionError,
|
ProxyInputFileResolutionError,
|
||||||
hasNonImageFileInputInOpenAiBody,
|
hasNonImageFileInputInOpenAiBody,
|
||||||
resolveOpenAiBodyInputFiles,
|
|
||||||
resolveResponsesBodyInputFiles,
|
resolveResponsesBodyInputFiles,
|
||||||
} from '../../services/proxyInputFileResolver.js';
|
} from '../../services/proxyInputFileResolver.js';
|
||||||
|
|
||||||
@@ -36,6 +35,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
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 {
|
function hasResponsesReasoningRequest(value: unknown): boolean {
|
||||||
if (!isRecord(value)) return false;
|
if (!isRecord(value)) return false;
|
||||||
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
|
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 {
|
function wantsNativeResponsesReasoning(body: unknown): boolean {
|
||||||
if (!isRecord(body)) return false;
|
if (!isRecord(body)) return false;
|
||||||
|
const include = normalizeIncludeList(body.include);
|
||||||
const include = Array.isArray(body.include)
|
if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) {
|
||||||
? body.include
|
return true;
|
||||||
: (typeof body.include === 'string' ? [body.include] : []);
|
}
|
||||||
|
if (carriesResponsesReasoningContinuity(body.input)) {
|
||||||
return include.some((item) => (
|
return true;
|
||||||
typeof item === 'string'
|
}
|
||||||
&& item.trim().toLowerCase() === 'reasoning.encrypted_content'
|
if (hasExplicitInclude(body)) {
|
||||||
)) || hasResponsesReasoningRequest(body.reasoning);
|
return false;
|
||||||
|
}
|
||||||
|
return hasResponsesReasoningRequest(body.reasoning);
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||||
@@ -103,14 +179,19 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
excludeChannelIds.push(selected.channel.id);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
const modelName = selected.actualModel || requestedModel;
|
const modelName = selected.actualModel || requestedModel;
|
||||||
const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(body, modelName, isStream);
|
|
||||||
const owner = getProxyResourceOwner(request);
|
const owner = getProxyResourceOwner(request);
|
||||||
let resolvedOpenAiBody = openAiBody;
|
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
|
||||||
let resolvedResponsesBody = body;
|
request.headers as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
let normalizedResponsesBody = openAiResponsesTransformer.inbound.sanitizeProxyBody(
|
||||||
|
body,
|
||||||
|
modelName,
|
||||||
|
isStream,
|
||||||
|
{ defaultEncryptedReasoningInclude },
|
||||||
|
);
|
||||||
if (owner) {
|
if (owner) {
|
||||||
try {
|
try {
|
||||||
resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(openAiBody, owner);
|
normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner);
|
||||||
resolvedResponsesBody = await resolveResponsesBodyInputFiles(body, owner);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ProxyInputFileResolutionError) {
|
if (error instanceof ProxyInputFileResolutionError) {
|
||||||
return reply.code(error.statusCode).send(error.payload);
|
return reply.code(error.statusCode).send(error.payload);
|
||||||
@@ -118,8 +199,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
|
const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(
|
||||||
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(resolvedResponsesBody);
|
normalizedResponsesBody,
|
||||||
|
modelName,
|
||||||
|
isStream,
|
||||||
|
{ defaultEncryptedReasoningInclude },
|
||||||
|
);
|
||||||
|
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
|
||||||
|
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
|
||||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
@@ -152,9 +239,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
tokenValue: selected.tokenValue,
|
tokenValue: selected.tokenValue,
|
||||||
sitePlatform: selected.site.platform,
|
sitePlatform: selected.site.platform,
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
openaiBody: resolvedOpenAiBody,
|
openaiBody: openAiBody,
|
||||||
downstreamFormat: 'responses',
|
downstreamFormat: 'responses',
|
||||||
responsesOriginalBody: resolvedResponsesBody,
|
responsesOriginalBody: normalizedResponsesBody,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
const upstreamPath = (
|
const upstreamPath = (
|
||||||
|
|||||||
@@ -358,6 +358,41 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves top-level reasoning_signature when rebuilding assistant thinking blocks for messages fallback', () => {
|
||||||
|
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'final answer',
|
||||||
|
reasoning_content: 'internal only',
|
||||||
|
reasoning_signature: 'metapi:anthropic-signature:sig-top-level-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'claude-opus-4-6',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body.messages).toEqual([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'internal only',
|
||||||
|
signature: 'sig-top-level-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'final answer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('strips cache_control from thinking blocks and empty text blocks', () => {
|
it('strips cache_control from thinking blocks and empty text blocks', () => {
|
||||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -301,9 +301,10 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
|||||||
|
|
||||||
if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') {
|
if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') {
|
||||||
const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data);
|
const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data);
|
||||||
if (!text) return null;
|
|
||||||
const signature = type === 'redacted_thinking' ? undefined : resolveAnthropicThinkingSignature(item);
|
const signature = type === 'redacted_thinking' ? undefined : resolveAnthropicThinkingSignature(item);
|
||||||
if (signature === null) return null;
|
if (signature === null) return null;
|
||||||
|
const hasThinkingCarrier = type !== 'redacted_thinking' && signature !== undefined;
|
||||||
|
if (!text && !hasThinkingCarrier) return null;
|
||||||
const next: Record<string, unknown> = { ...item };
|
const next: Record<string, unknown> = { ...item };
|
||||||
if (type === 'redacted_thinking') {
|
if (type === 'redacted_thinking') {
|
||||||
next.type = 'redacted_thinking';
|
next.type = 'redacted_thinking';
|
||||||
@@ -772,6 +773,15 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
|
|||||||
if (role !== 'assistant') return;
|
if (role !== 'assistant') return;
|
||||||
|
|
||||||
const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content);
|
const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content);
|
||||||
|
const reasoningCarrier = sanitizeAnthropicContentBlock({
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: asTrimmedString(item.reasoning_content ?? item.reasoning),
|
||||||
|
reasoning_signature: item.reasoning_signature,
|
||||||
|
signature: item.signature,
|
||||||
|
});
|
||||||
|
if (reasoningCarrier) {
|
||||||
|
contentBlocks.unshift(reasoningCarrier);
|
||||||
|
}
|
||||||
|
|
||||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createGeminiGenerateContentAggregateState,
|
createGeminiGenerateContentAggregateState,
|
||||||
} from './aggregator.js';
|
} from './aggregator.js';
|
||||||
import {
|
import {
|
||||||
|
extractTransformerMetadata,
|
||||||
extractResponseMetadata,
|
extractResponseMetadata,
|
||||||
serializeGeminiAggregateResponse,
|
serializeGeminiAggregateResponse,
|
||||||
} from './outbound.js';
|
} from './outbound.js';
|
||||||
@@ -240,6 +241,60 @@ describe('Gemini aggregate/outbound/usage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('projects Gemini richer metadata into the shared transformer metadata contract', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
applyGeminiGenerateContentAggregate(state, {
|
||||||
|
responseId: 'resp-shared-meta',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
groundingMetadata: { webSearchQueries: ['cats'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/cats' }] },
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'reasoning', thought: true, thoughtSignature: 'sig-final' },
|
||||||
|
{ text: 'answer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 15,
|
||||||
|
candidatesTokenCount: 5,
|
||||||
|
totalTokenCount: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = extractTransformerMetadata(state, {
|
||||||
|
cachedContent: 'cached/item-1',
|
||||||
|
safetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }],
|
||||||
|
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
|
||||||
|
generationConfig: {
|
||||||
|
imageConfig: { aspectRatio: '16:9' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metadata).toEqual({
|
||||||
|
citations: [{ citations: [{ uri: 'https://example.com/cats' }] }],
|
||||||
|
groundingMetadata: [{ webSearchQueries: ['cats'] }],
|
||||||
|
thoughtSignature: 'sig-final',
|
||||||
|
thoughtSignatures: ['sig-final'],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 15,
|
||||||
|
candidatesTokenCount: 5,
|
||||||
|
totalTokenCount: 24,
|
||||||
|
},
|
||||||
|
geminiSafetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }],
|
||||||
|
geminiImageConfig: { aspectRatio: '16:9' },
|
||||||
|
passthrough: {
|
||||||
|
cachedContent: 'cached/item-1',
|
||||||
|
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('merges split candidate metadata and emits top-level metadata in candidate order', () => {
|
it('merges split candidate metadata and emits top-level metadata in candidate order', () => {
|
||||||
const state = createGeminiGenerateContentAggregateState();
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
applyGeminiGenerateContentAggregate(state, [
|
applyGeminiGenerateContentAggregate(state, [
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
createGeminiGenerateContentAggregateState,
|
createGeminiGenerateContentAggregateState,
|
||||||
type GeminiGenerateContentAggregateState,
|
type GeminiGenerateContentAggregateState,
|
||||||
} from './aggregator.js';
|
} from './aggregator.js';
|
||||||
|
import {
|
||||||
|
toTransformerMetadataRecord,
|
||||||
|
type TransformerMetadata,
|
||||||
|
} from '../../shared/normalized.js';
|
||||||
|
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
return (baseUrl || '').replace(/\/+$/, '');
|
||||||
@@ -176,14 +180,28 @@ function extractOrderedThoughtSignatures(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
function ensurePassthrough(metadata: TransformerMetadata): Record<string, unknown> {
|
||||||
|
if (!metadata.passthrough) metadata.passthrough = {};
|
||||||
|
return metadata.passthrough;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRequestSemantics(requestPayload: unknown): TransformerMetadata {
|
||||||
if (!isRecord(requestPayload)) return {};
|
if (!isRecord(requestPayload)) return {};
|
||||||
|
|
||||||
const metadata: GeminiRecord = {};
|
const metadata: TransformerMetadata = {};
|
||||||
if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
const passthrough = ensurePassthrough(metadata);
|
||||||
if (requestPayload.cachedContent !== undefined) metadata.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
if (requestPayload.systemInstruction !== undefined) {
|
||||||
if (requestPayload.safetySettings !== undefined) metadata.safetySettings = cloneJsonValue(requestPayload.safetySettings);
|
passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||||
if (requestPayload.toolConfig !== undefined) metadata.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
}
|
||||||
|
if (requestPayload.cachedContent !== undefined) {
|
||||||
|
passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||||
|
}
|
||||||
|
if (requestPayload.safetySettings !== undefined) {
|
||||||
|
metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||||
|
}
|
||||||
|
if (requestPayload.toolConfig !== undefined) {
|
||||||
|
passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||||
|
}
|
||||||
|
|
||||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||||
if (generationConfig) {
|
if (generationConfig) {
|
||||||
@@ -207,7 +225,11 @@ function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
|||||||
];
|
];
|
||||||
for (const key of preservedKeys) {
|
for (const key of preservedKeys) {
|
||||||
if (generationConfig[key] !== undefined) {
|
if (generationConfig[key] !== undefined) {
|
||||||
metadata[key] = cloneJsonValue(generationConfig[key]);
|
if (key === 'imageConfig') {
|
||||||
|
metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]);
|
||||||
|
} else {
|
||||||
|
passthrough[key] = cloneJsonValue(generationConfig[key]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,10 +248,14 @@ function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
|||||||
.filter((item) => Object.keys(item).length > 0);
|
.filter((item) => Object.keys(item).length > 0);
|
||||||
|
|
||||||
if (requestTools.length > 0) {
|
if (requestTools.length > 0) {
|
||||||
metadata.tools = requestTools;
|
passthrough.tools = requestTools;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) {
|
||||||
|
delete metadata.passthrough;
|
||||||
|
}
|
||||||
|
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,9 +277,9 @@ export function serializeGeminiAggregateResponse(
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
export function extractTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata {
|
||||||
const state = ensureAggregateState(payload);
|
const state = ensureAggregateState(payload);
|
||||||
const metadata: GeminiRecord = extractRequestSemantics(requestPayload);
|
const metadata = extractRequestSemantics(requestPayload);
|
||||||
|
|
||||||
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
|
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
|
||||||
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
|
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
|
||||||
@@ -273,10 +299,15 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno
|
|||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
||||||
|
return toTransformerMetadataRecord(extractTransformerMetadata(payload, requestPayload)) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
export const geminiGenerateContentOutbound = {
|
export const geminiGenerateContentOutbound = {
|
||||||
resolveBaseUrl,
|
resolveBaseUrl,
|
||||||
resolveModelsUrl,
|
resolveModelsUrl,
|
||||||
resolveActionUrl,
|
resolveActionUrl,
|
||||||
|
extractTransformerMetadata,
|
||||||
extractResponseMetadata,
|
extractResponseMetadata,
|
||||||
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
OpenAiChatToolCall,
|
OpenAiChatToolCall,
|
||||||
OpenAiChatUsageDetails,
|
OpenAiChatUsageDetails,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
|
import { fromTransformerMetadataRecord } from '../../shared/normalized.js';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
@@ -345,7 +346,9 @@ function extractCitationsFromPayload(
|
|||||||
citations.add(citation);
|
citations.add(citation);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformerMetadata = asRecord(payload.transformer_metadata) ?? asRecord(payload.transformerMetadata);
|
const transformerMetadata = fromTransformerMetadataRecord(
|
||||||
|
payload.transformer_metadata ?? payload.transformerMetadata,
|
||||||
|
);
|
||||||
if (transformerMetadata) {
|
if (transformerMetadata) {
|
||||||
for (const citation of dedupeCitations(transformerMetadata.citations)) {
|
for (const citation of dedupeCitations(transformerMetadata.citations)) {
|
||||||
citations.add(citation);
|
citations.add(citation);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createClaudeDownstreamContext } from '../../shared/normalized.js';
|
import { createClaudeDownstreamContext } from '../../shared/normalized.js';
|
||||||
|
import { toTransformerMetadataRecord } from '../../shared/normalized.js';
|
||||||
import { openAiChatTransformer } from './index.js';
|
import { openAiChatTransformer } from './index.js';
|
||||||
|
|
||||||
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
||||||
@@ -154,6 +155,32 @@ describe('openAiChatTransformer.outbound', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('consumes shared transformer metadata citations on openai-compatible final responses', () => {
|
||||||
|
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||||
|
id: 'chatcmpl-shared-metadata',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 123,
|
||||||
|
transformer_metadata: toTransformerMetadataRecord({
|
||||||
|
citations: [{ uri: 'https://shared.example/citation' }],
|
||||||
|
thoughtSignature: 'sig-final',
|
||||||
|
thoughtSignatures: ['sig-tool', 'sig-final'],
|
||||||
|
passthrough: {
|
||||||
|
cachedContent: 'cached/item-1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'hello',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}, 'gpt-5');
|
||||||
|
|
||||||
|
expect((normalized as any).citations).toEqual(['https://shared.example/citation']);
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => {
|
it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => {
|
||||||
const normalized = openAiChatTransformer.transformFinalResponse({
|
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||||
id: 'chatcmpl-multi',
|
id: 'chatcmpl-multi',
|
||||||
|
|||||||
@@ -81,7 +81,31 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults encrypted reasoning include when reasoning is requested without an explicit include list', () => {
|
it('defaults encrypted reasoning include when reasoning is requested without an explicit include list on the codex responses surface', () => {
|
||||||
|
const result = sanitizeResponsesBodyForProxy(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
summary: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
{ defaultEncryptedReasoningInclude: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
include: ['reasoning.encrypted_content'],
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
summary: 'auto',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not inject default include on generic responses requests when the codex surface default is disabled', () => {
|
||||||
const result = sanitizeResponsesBodyForProxy(
|
const result = sanitizeResponsesBodyForProxy(
|
||||||
{
|
{
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
@@ -95,8 +119,68 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(result.include).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults encrypted reasoning include on codex responses requests even without explicit reasoning config', () => {
|
||||||
|
const result = sanitizeResponsesBodyForProxy(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
{ defaultEncryptedReasoningInclude: true },
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
include: ['reasoning.encrypted_content'],
|
include: ['reasoning.encrypted_content'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects an explicit empty include list when the codex responses default is enabled', () => {
|
||||||
|
const result = sanitizeResponsesBodyForProxy(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
summary: 'auto',
|
||||||
|
},
|
||||||
|
include: [],
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
{ defaultEncryptedReasoningInclude: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
include: [],
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
summary: 'auto',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects an explicit custom include list when the codex responses default is enabled', () => {
|
||||||
|
const result = sanitizeResponsesBodyForProxy(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
summary: 'auto',
|
||||||
|
},
|
||||||
|
include: ['message.input_image.image_url'],
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
{ defaultEncryptedReasoningInclude: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
include: ['message.input_image.image_url'],
|
||||||
reasoning: {
|
reasoning: {
|
||||||
effort: 'high',
|
effort: 'high',
|
||||||
summary: 'auto',
|
summary: 'auto',
|
||||||
@@ -296,6 +380,19 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not inject default include when converting non-responses OpenAI input into Responses bodies', () => {
|
||||||
|
const result = convertOpenAiBodyToResponsesBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.include).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('maps OpenAI file-style content blocks into Responses input_file blocks', () => {
|
it('maps OpenAI file-style content blocks into Responses input_file blocks', () => {
|
||||||
const result = convertOpenAiBodyToResponsesBody(
|
const result = convertOpenAiBodyToResponsesBody(
|
||||||
{
|
{
|
||||||
@@ -557,7 +654,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds encrypted reasoning include for OpenAI-compatible fallback when reasoning options are present', () => {
|
it('does not inject default include for generic OpenAI-compatible fallback when reasoning options are present', () => {
|
||||||
const result = convertResponsesBodyToOpenAiBody(
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
{
|
{
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
@@ -570,6 +667,23 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(result.include).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds encrypted reasoning include for OpenAI-compatible fallback when the codex surface default is enabled', () => {
|
||||||
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
true,
|
||||||
|
{ defaultEncryptedReasoningInclude: true },
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
include: ['reasoning.encrypted_content'],
|
include: ['reasoning.encrypted_content'],
|
||||||
reasoning: {
|
reasoning: {
|
||||||
@@ -578,6 +692,81 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not inject default include for generic OpenAI-compatible fallback when responses input omits include and reasoning', () => {
|
||||||
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.include).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds encrypted reasoning include for OpenAI-compatible fallback when the codex surface default is enabled even without explicit reasoning config', () => {
|
||||||
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
true,
|
||||||
|
{ defaultEncryptedReasoningInclude: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
include: ['reasoning.encrypted_content'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects an explicit empty include list for OpenAI-compatible fallback when the codex surface default is enabled', () => {
|
||||||
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
},
|
||||||
|
include: [],
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
true,
|
||||||
|
{ defaultEncryptedReasoningInclude: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
include: [],
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects an explicit custom include list for OpenAI-compatible fallback when the codex surface default is enabled', () => {
|
||||||
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
},
|
||||||
|
include: ['message.input_image.image_url'],
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
true,
|
||||||
|
{ defaultEncryptedReasoningInclude: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
include: ['message.input_image.image_url'],
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps Responses input_file items when converting back to OpenAI-compatible bodies', () => {
|
it('keeps Responses input_file items when converting back to OpenAI-compatible bodies', () => {
|
||||||
const result = convertResponsesBodyToOpenAiBody(
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,30 +82,17 @@ function normalizeIncludeList(value: unknown): unknown {
|
|||||||
.filter((item) => item.length > 0);
|
.filter((item) => item.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasReasoningRequest(value: unknown): boolean {
|
function hasExplicitInclude(body: Record<string, unknown>): boolean {
|
||||||
if (!isRecord(value)) return false;
|
return Object.prototype.hasOwnProperty.call(body, 'include');
|
||||||
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
|
|
||||||
return relevantKeys.some((key) => {
|
|
||||||
const entry = value[key];
|
|
||||||
if (typeof entry === 'string') return entry.trim().length > 0;
|
|
||||||
return entry !== undefined && entry !== null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureEncryptedReasoningInclude(body: Record<string, unknown>): void {
|
function applyDefaultResponsesInclude(body: Record<string, unknown>): void {
|
||||||
if (!hasReasoningRequest(body.reasoning)) return;
|
if (hasExplicitInclude(body)) {
|
||||||
|
body.include = normalizeIncludeList(body.include);
|
||||||
const normalizedInclude = normalizeIncludeList(body.include);
|
|
||||||
const includeList = Array.isArray(normalizedInclude) ? normalizedInclude : [];
|
|
||||||
const alreadyRequested = includeList.some((item) => (
|
|
||||||
typeof item === 'string' && item.trim().toLowerCase() === 'reasoning.encrypted_content'
|
|
||||||
));
|
|
||||||
if (alreadyRequested) {
|
|
||||||
body.include = includeList;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.include = [...includeList, 'reasoning.encrypted_content'];
|
body.include = ['reasoning.encrypted_content'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTextConfig(
|
function normalizeTextConfig(
|
||||||
@@ -137,7 +124,10 @@ function normalizeStreamOptions(rawStreamOptions: unknown): unknown {
|
|||||||
|
|
||||||
function normalizeResponsesRequestFieldParity(
|
function normalizeResponsesRequestFieldParity(
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
options?: { verbositySource?: unknown },
|
options?: {
|
||||||
|
verbositySource?: unknown;
|
||||||
|
defaultEncryptedReasoningInclude?: boolean;
|
||||||
|
},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const normalized: Record<string, unknown> = { ...body };
|
const normalized: Record<string, unknown> = { ...body };
|
||||||
|
|
||||||
@@ -174,7 +164,9 @@ function normalizeResponsesRequestFieldParity(
|
|||||||
if (normalized.include !== undefined) {
|
if (normalized.include !== undefined) {
|
||||||
normalized.include = normalizeIncludeList(normalized.include);
|
normalized.include = normalizeIncludeList(normalized.include);
|
||||||
}
|
}
|
||||||
ensureEncryptedReasoningInclude(normalized);
|
if (options?.defaultEncryptedReasoningInclude) {
|
||||||
|
applyDefaultResponsesInclude(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
if (normalized.stream_options !== undefined) {
|
if (normalized.stream_options !== undefined) {
|
||||||
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
|
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
|
||||||
@@ -376,6 +368,7 @@ export function sanitizeResponsesBodyForProxy(
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
modelName: string,
|
modelName: string,
|
||||||
stream: boolean,
|
stream: boolean,
|
||||||
|
options?: { defaultEncryptedReasoningInclude?: boolean },
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
let normalized = normalizeResponsesBodyForCompatibility({
|
let normalized = normalizeResponsesBodyForCompatibility({
|
||||||
...body,
|
...body,
|
||||||
@@ -401,6 +394,7 @@ export function sanitizeResponsesBodyForProxy(
|
|||||||
|
|
||||||
normalized = normalizeResponsesRequestFieldParity(normalized, {
|
normalized = normalizeResponsesRequestFieldParity(normalized, {
|
||||||
verbositySource: body.verbosity,
|
verbositySource: body.verbosity,
|
||||||
|
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sanitized: Record<string, unknown> = {};
|
const sanitized: Record<string, unknown> = {};
|
||||||
@@ -717,8 +711,11 @@ export function convertResponsesBodyToOpenAiBody(
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
modelName: string,
|
modelName: string,
|
||||||
stream: boolean,
|
stream: boolean,
|
||||||
|
options?: { defaultEncryptedReasoningInclude?: boolean },
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const normalizedBody = normalizeResponsesRequestFieldParity(body);
|
const normalizedBody = normalizeResponsesRequestFieldParity(body, {
|
||||||
|
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
|
||||||
|
});
|
||||||
const messages: Array<Record<string, unknown>> = [];
|
const messages: Array<Record<string, unknown>> = [];
|
||||||
const input = normalizedBody.input;
|
const input = normalizedBody.input;
|
||||||
let functionCallIndex = 0;
|
let functionCallIndex = 0;
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
fromTransformerMetadataRecord,
|
||||||
normalizeStopReason,
|
normalizeStopReason,
|
||||||
normalizeUpstreamFinalResponse,
|
normalizeUpstreamFinalResponse,
|
||||||
pullSseEventsWithDone,
|
pullSseEventsWithDone,
|
||||||
serializeFinalResponse,
|
serializeFinalResponse,
|
||||||
|
toTransformerMetadataRecord,
|
||||||
type NormalizedFinalResponse,
|
type NormalizedFinalResponse,
|
||||||
|
type TransformerMetadata,
|
||||||
} from './normalized.js';
|
} from './normalized.js';
|
||||||
|
|
||||||
describe('shared normalized helpers', () => {
|
describe('shared normalized helpers', () => {
|
||||||
@@ -143,6 +146,27 @@ describe('shared normalized helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips shared transformer metadata through transport-safe records', () => {
|
||||||
|
const metadata: TransformerMetadata = {
|
||||||
|
promptCacheKey: 'cache-key',
|
||||||
|
truncation: 'auto',
|
||||||
|
serviceTier: 'priority',
|
||||||
|
citations: [{ uri: 'https://example.com/citation' }],
|
||||||
|
thoughtSignature: 'sig-final',
|
||||||
|
thoughtSignatures: ['sig-tool', 'sig-final'],
|
||||||
|
geminiSafetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }],
|
||||||
|
geminiImageConfig: { aspectRatio: '16:9' },
|
||||||
|
groundingMetadata: [{ webSearchQueries: ['cats'] }],
|
||||||
|
usageMetadata: { totalTokenCount: 42 },
|
||||||
|
passthrough: {
|
||||||
|
cachedContent: 'cached/item-1',
|
||||||
|
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fromTransformerMetadataRecord(toTransformerMetadataRecord(metadata))).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes known stop reasons', () => {
|
it('normalizes known stop reasons', () => {
|
||||||
expect(normalizeStopReason('max_output_tokens')).toBe('length');
|
expect(normalizeStopReason('max_output_tokens')).toBe('length');
|
||||||
expect(normalizeStopReason('tool_use')).toBe('tool_calls');
|
expect(normalizeStopReason('tool_use')).toBe('tool_calls');
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export type TransformerMetadata = {
|
|||||||
includeObfuscation?: boolean | null;
|
includeObfuscation?: boolean | null;
|
||||||
citations?: unknown;
|
citations?: unknown;
|
||||||
annotations?: unknown;
|
annotations?: unknown;
|
||||||
|
groundingMetadata?: unknown;
|
||||||
|
usageMetadata?: unknown;
|
||||||
geminiSafetySettings?: unknown;
|
geminiSafetySettings?: unknown;
|
||||||
geminiImageConfig?: unknown;
|
geminiImageConfig?: unknown;
|
||||||
thoughtSignature?: string | null;
|
thoughtSignature?: string | null;
|
||||||
@@ -147,3 +149,133 @@ export function mergeNormalizedUsage(
|
|||||||
rejectedPredictionTokens: (merged.rejectedPredictionTokens || 0) + (incoming.rejectedPredictionTokens || 0),
|
rejectedPredictionTokens: (merged.rejectedPredictionTokens || 0) + (incoming.rejectedPredictionTokens || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneJsonValue<T>(value: T): T {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => cloneJsonValue(item)) as T;
|
||||||
|
}
|
||||||
|
if (isRecord(value)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||||
|
) as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSFORMER_METADATA_PASSTHROUGH_KEYS = new Set([
|
||||||
|
'systemInstruction',
|
||||||
|
'cachedContent',
|
||||||
|
'toolConfig',
|
||||||
|
'tools',
|
||||||
|
'stopSequences',
|
||||||
|
'responseModalities',
|
||||||
|
'responseMimeType',
|
||||||
|
'responseSchema',
|
||||||
|
'candidateCount',
|
||||||
|
'maxOutputTokens',
|
||||||
|
'temperature',
|
||||||
|
'topP',
|
||||||
|
'topK',
|
||||||
|
'presencePenalty',
|
||||||
|
'frequencyPenalty',
|
||||||
|
'seed',
|
||||||
|
'responseLogprobs',
|
||||||
|
'logprobs',
|
||||||
|
'thinkingConfig',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function toTransformerMetadataRecord(
|
||||||
|
metadata: TransformerMetadata | undefined,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!metadata) return undefined;
|
||||||
|
|
||||||
|
const record: Record<string, unknown> = {};
|
||||||
|
if (metadata.include !== undefined) record.include = cloneJsonValue(metadata.include);
|
||||||
|
if (metadata.maxToolCalls !== undefined) record.maxToolCalls = metadata.maxToolCalls;
|
||||||
|
if (metadata.promptCacheKey !== undefined) record.promptCacheKey = cloneJsonValue(metadata.promptCacheKey);
|
||||||
|
if (metadata.promptCacheRetention !== undefined) {
|
||||||
|
record.promptCacheRetention = cloneJsonValue(metadata.promptCacheRetention);
|
||||||
|
}
|
||||||
|
if (metadata.truncation !== undefined) record.truncation = cloneJsonValue(metadata.truncation);
|
||||||
|
if (metadata.serviceTier !== undefined) record.serviceTier = cloneJsonValue(metadata.serviceTier);
|
||||||
|
if (metadata.includeObfuscation !== undefined) record.includeObfuscation = metadata.includeObfuscation;
|
||||||
|
if (metadata.citations !== undefined) record.citations = cloneJsonValue(metadata.citations);
|
||||||
|
if (metadata.annotations !== undefined) record.annotations = cloneJsonValue(metadata.annotations);
|
||||||
|
if (metadata.groundingMetadata !== undefined) {
|
||||||
|
record.groundingMetadata = cloneJsonValue(metadata.groundingMetadata);
|
||||||
|
}
|
||||||
|
if (metadata.usageMetadata !== undefined) {
|
||||||
|
record.usageMetadata = cloneJsonValue(metadata.usageMetadata);
|
||||||
|
}
|
||||||
|
if (metadata.geminiSafetySettings !== undefined) {
|
||||||
|
record.safetySettings = cloneJsonValue(metadata.geminiSafetySettings);
|
||||||
|
}
|
||||||
|
if (metadata.geminiImageConfig !== undefined) {
|
||||||
|
record.imageConfig = cloneJsonValue(metadata.geminiImageConfig);
|
||||||
|
}
|
||||||
|
if (metadata.thoughtSignature !== undefined) record.thoughtSignature = metadata.thoughtSignature;
|
||||||
|
if (metadata.thoughtSignatures !== undefined) {
|
||||||
|
record.thoughtSignatures = cloneJsonValue(metadata.thoughtSignatures);
|
||||||
|
}
|
||||||
|
if (isRecord(metadata.passthrough)) {
|
||||||
|
for (const [key, value] of Object.entries(metadata.passthrough)) {
|
||||||
|
if (record[key] === undefined) {
|
||||||
|
record[key] = cloneJsonValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(record).length > 0 ? record : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromTransformerMetadataRecord(
|
||||||
|
value: unknown,
|
||||||
|
): TransformerMetadata | undefined {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
|
||||||
|
const metadata: TransformerMetadata = {};
|
||||||
|
if (value.include !== undefined) metadata.include = cloneJsonValue(value.include);
|
||||||
|
if (typeof value.maxToolCalls === 'number' && Number.isFinite(value.maxToolCalls)) {
|
||||||
|
metadata.maxToolCalls = value.maxToolCalls;
|
||||||
|
}
|
||||||
|
if (value.promptCacheKey !== undefined) metadata.promptCacheKey = cloneJsonValue(value.promptCacheKey);
|
||||||
|
if (value.promptCacheRetention !== undefined) {
|
||||||
|
metadata.promptCacheRetention = cloneJsonValue(value.promptCacheRetention);
|
||||||
|
}
|
||||||
|
if (value.truncation !== undefined) metadata.truncation = cloneJsonValue(value.truncation);
|
||||||
|
if (value.serviceTier !== undefined) metadata.serviceTier = cloneJsonValue(value.serviceTier);
|
||||||
|
if (typeof value.includeObfuscation === 'boolean') metadata.includeObfuscation = value.includeObfuscation;
|
||||||
|
if (value.citations !== undefined) metadata.citations = cloneJsonValue(value.citations);
|
||||||
|
if (value.annotations !== undefined) metadata.annotations = cloneJsonValue(value.annotations);
|
||||||
|
if (value.groundingMetadata !== undefined) {
|
||||||
|
metadata.groundingMetadata = cloneJsonValue(value.groundingMetadata);
|
||||||
|
}
|
||||||
|
if (value.usageMetadata !== undefined) {
|
||||||
|
metadata.usageMetadata = cloneJsonValue(value.usageMetadata);
|
||||||
|
}
|
||||||
|
if (value.safetySettings !== undefined) {
|
||||||
|
metadata.geminiSafetySettings = cloneJsonValue(value.safetySettings);
|
||||||
|
}
|
||||||
|
if (value.imageConfig !== undefined) {
|
||||||
|
metadata.geminiImageConfig = cloneJsonValue(value.imageConfig);
|
||||||
|
}
|
||||||
|
if (typeof value.thoughtSignature === 'string') metadata.thoughtSignature = value.thoughtSignature;
|
||||||
|
if (Array.isArray(value.thoughtSignatures)) {
|
||||||
|
metadata.thoughtSignatures = value.thoughtSignatures
|
||||||
|
.filter((item): item is string => typeof item === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passthroughEntries = Object.entries(value)
|
||||||
|
.filter(([key]) => TRANSFORMER_METADATA_PASSTHROUGH_KEYS.has(key));
|
||||||
|
if (passthroughEntries.length > 0) {
|
||||||
|
metadata.passthrough = Object.fromEntries(
|
||||||
|
passthroughEntries.map(([key, entry]) => [key, cloneJsonValue(entry)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user