feat: implement proxy input file resolution and storage

- Added `inputFiles.ts` for handling input file resolution, including validation and normalization of file types.
- Introduced `proxyFileStore.ts` for managing file storage, retrieval, and deletion with owner-based isolation.
- Created tests for proxy file storage functionality in `proxyFileStore.test.ts`.
- Developed `proxyInputFileResolver.ts` to resolve input files in OpenAI and response formats.
- Added utility functions for handling reasoning signatures in `reasoningTransport.ts` and corresponding tests.
- Enhanced shared input file transformers in `inputFile.ts` for better file handling and type inference.
This commit is contained in:
cita
2026-03-08 22:10:31 +08:00
parent ede8998244
commit 7bd3114ea0
54 changed files with 4504 additions and 164 deletions
@@ -109,6 +109,130 @@ describe('serializeConvertedResponsesEvents', () => {
});
});
it('keeps encrypted-only reasoning output items during stream aggregation', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.output_item.added',
responsesPayload: {
type: 'response.output_item.added',
output_index: 0,
item: {
type: 'reasoning',
encrypted_content: 'enc-only',
},
},
},
});
const completedLines = serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.completed',
responsesPayload: {
type: 'response.completed',
response: {
id: 'resp_enc_only',
model: 'gpt-5',
usage: {
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
},
},
},
},
});
const payloads = parseSsePayloads(completedLines);
const completed = payloads.find((item) => item.type === 'response.completed');
expect(completed).toBeTruthy();
expect(completed?.response?.output?.[0]).toMatchObject({
type: 'reasoning',
encrypted_content: 'enc-only',
});
expect((completed?.response?.output?.[0] as any)?.id).toMatch(/^rs_/);
});
it('starts a synthetic reasoning item when fallback streams only carry encrypted reasoning signatures', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
};
const signatureLines = serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
reasoningSignature: 'enc-stream-only',
} as any,
});
const signaturePayloads = parseSsePayloads(signatureLines);
expect(signaturePayloads).toEqual([
{
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'rs_0',
type: 'reasoning',
status: 'in_progress',
summary: [],
encrypted_content: 'enc-stream-only',
},
},
]);
const completedLines = serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.completed',
responsesPayload: {
type: 'response.completed',
response: {
id: 'resp_enc_stream_only',
model: 'gpt-5',
usage: {
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
},
},
},
},
});
const payloads = parseSsePayloads(completedLines);
const completed = payloads.find((item) => item.type === 'response.completed');
expect(completed?.response?.output).toEqual([
{
id: 'rs_0',
type: 'reasoning',
status: 'completed',
summary: [],
encrypted_content: 'enc-stream-only',
},
]);
});
it('preserves richer image_generation_call fields while aggregating progress events', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
@@ -218,7 +218,9 @@ function ensureReasoningItem(
indexHint?: unknown,
): { index: number; item: AggregateOutputItem; created: boolean } {
const itemId = asTrimmedString(itemIdRaw);
const existingIndex = itemId ? state.reasoningIndexById[itemId] : undefined;
const existingIndex = itemId
? state.reasoningIndexById[itemId]
: Object.values(state.reasoningIndexById)[0];
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
const created = !state.outputItems[index];
const item = ensureOutputItem(state, index, () => ({
@@ -236,7 +238,15 @@ function ensureReasoningSummaryPart(
itemIdRaw: unknown,
summaryIndexRaw: unknown,
indexHint?: unknown,
): { item: AggregateOutputItem; summary: AggregateOutputItem; index: number; summaryIndex: number; created: boolean } {
): {
item: AggregateOutputItem;
summary: AggregateOutputItem;
index: number;
summaryIndex: number;
created: boolean;
itemCreated: boolean;
partCreated: boolean;
} {
const reasoningState = ensureReasoningItem(state, itemIdRaw, indexHint);
const summaryIndex = typeof summaryIndexRaw === 'number' && Number.isFinite(summaryIndexRaw)
? Math.max(0, Math.trunc(summaryIndexRaw))
@@ -244,12 +254,20 @@ function ensureReasoningSummaryPart(
const summary = Array.isArray(reasoningState.item.summary) ? reasoningState.item.summary as AggregateOutputItem[] : [];
if (!Array.isArray(reasoningState.item.summary)) reasoningState.item.summary = summary;
let part = summary[summaryIndex];
const created = !isRecord(part);
const partCreated = !isRecord(part);
if (!isRecord(part)) {
part = { type: 'summary_text', text: '' };
summary[summaryIndex] = part;
}
return { item: reasoningState.item, summary: part, index: reasoningState.index, summaryIndex, created: reasoningState.created || created };
return {
item: reasoningState.item,
summary: part,
index: reasoningState.index,
summaryIndex,
created: reasoningState.created || partCreated,
itemCreated: reasoningState.created,
partCreated,
};
}
function ensureFunctionCallItem(
@@ -458,6 +476,23 @@ function applyOriginalResponsesPayload(
const outputIndex = resolveOutputIndex(state, payload.output_index, (payload.item as Record<string, unknown> | undefined)?.id);
const item = cloneRecord(payload.item) || {};
if (Object.keys(item).length > 0) {
const itemType = asTrimmedString(item.type).toLowerCase();
if (itemType === 'reasoning') {
const reasoningState = ensureReasoningItem(state, item.id, outputIndex);
const next = {
...reasoningState.item,
...item,
};
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary)) {
next.summary = reasoningState.item.summary;
}
setOutputItem(state, reasoningState.index, next);
return serializeOriginalResponsesEvent(eventType, {
...payload,
output_index: reasoningState.index,
item: state.outputItems[reasoningState.index] ?? payload.item,
});
}
const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {};
const next = {
...existing,
@@ -641,19 +676,47 @@ function buildSyntheticMessageEvents(
function buildSyntheticReasoningEvents(
state: OpenAiResponsesAggregateState,
delta: string,
delta?: string,
reasoningSignature?: string,
): string[] {
const summaryState = ensureReasoningSummaryPart(state, '', 0, state.outputItems.length);
const itemId = asTrimmedString(summaryState.item.id);
const lines: string[] = [];
const signature = asTrimmedString(reasoningSignature);
const reasoningState = (signature || delta)
? ensureReasoningItem(state, '', state.outputItems.length)
: null;
if (reasoningState && signature) {
reasoningState.item.encrypted_content = signature;
if (reasoningState.created) {
lines.push(serializeSse('response.output_item.added', {
type: 'response.output_item.added',
output_index: reasoningState.index,
item: reasoningState.item,
}));
}
}
if (!delta) {
return lines;
}
const summaryState = ensureReasoningSummaryPart(
state,
reasoningState?.item.id ?? '',
0,
reasoningState?.index ?? state.outputItems.length,
);
const itemId = asTrimmedString(summaryState.item.id);
const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : '';
const novelDelta = computeNovelDelta(currentText, delta);
if (summaryState.created) {
if (summaryState.itemCreated && !reasoningState?.created) {
lines.push(serializeSse('response.output_item.added', {
type: 'response.output_item.added',
output_index: summaryState.index,
item: summaryState.item,
}));
}
if (summaryState.partCreated) {
lines.push(serializeSse('response.reasoning_summary_part.added', {
type: 'response.reasoning_summary_part.added',
item_id: itemId,
@@ -728,8 +791,8 @@ export function serializeConvertedResponsesEvents(input: {
if (event.contentDelta) {
lines.push(...buildSyntheticMessageEvents(state, event.contentDelta));
}
if (event.reasoningDelta) {
lines.push(...buildSyntheticReasoningEvents(state, event.reasoningDelta));
if (event.reasoningDelta || event.reasoningSignature) {
lines.push(...buildSyntheticReasoningEvents(state, event.reasoningDelta, event.reasoningSignature));
}
lines.push(...buildSyntheticToolEvents(state, event));
return lines;
@@ -1,3 +1,5 @@
import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -79,6 +81,11 @@ function normalizeResponsesContentItem(
};
}
if (type === 'file' || type === 'input_file') {
const fileBlock = normalizeInputFileBlock(item);
return fileBlock ? toResponsesInputFileBlock(fileBlock) : null;
}
if (type === 'function_call' || type === 'function_call_output') {
return item;
}
@@ -191,7 +198,39 @@ export function buildResponsesCompatibilityBodies(
};
push(stripResponsesMetadata(body));
push(buildCoreResponsesBody(body));
const coreModel = typeof body.model === 'string' ? body.model.trim() : '';
if (coreModel && body.input !== undefined) {
const richCandidate: Record<string, unknown> = {
model: coreModel,
input: body.input,
stream: body.stream === true,
};
const maxOutputTokens = toFiniteNumber(body.max_output_tokens);
if (maxOutputTokens !== null && maxOutputTokens > 0) {
richCandidate.max_output_tokens = Math.trunc(maxOutputTokens);
}
const temperature = toFiniteNumber(body.temperature);
if (temperature !== null) richCandidate.temperature = temperature;
const topP = toFiniteNumber(body.top_p);
if (topP !== null) richCandidate.top_p = topP;
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
if (instructions) richCandidate.instructions = instructions;
const passthroughFields = [
'reasoning',
'safety_identifier',
'max_tool_calls',
'prompt_cache_key',
'prompt_cache_retention',
'background',
'top_logprobs',
] as const;
for (const key of passthroughFields) {
if (body[key] === undefined) continue;
richCandidate[key] = cloneJsonValue(body[key]);
}
push(richCandidate);
}
push(buildStrictResponsesBody(body));
return candidates;
}
@@ -327,6 +366,18 @@ function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
function parseUpstreamErrorShape(rawText: string): {
type: string;
code: string;
@@ -383,6 +434,20 @@ function buildCoreResponsesBody(
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
if (instructions) core.instructions = instructions;
const passthroughFields = [
'reasoning',
'safety_identifier',
'max_tool_calls',
'prompt_cache_key',
'prompt_cache_retention',
'background',
'top_logprobs',
] as const;
for (const key of passthroughFields) {
if (body[key] === undefined) continue;
core[key] = cloneJsonValue(body[key]);
}
return core;
}
@@ -5,6 +5,7 @@ import {
convertResponsesBodyToOpenAiBody,
sanitizeResponsesBodyForProxy,
} from './conversion.js';
import { buildResponsesCompatibilityBodies } from './compatibility.js';
describe('sanitizeResponsesBodyForProxy', () => {
it('preserves newer Responses request fields needed by the proxy', () => {
@@ -79,9 +80,80 @@ describe('sanitizeResponsesBodyForProxy', () => {
service_tier: 'priority',
});
});
it('defaults encrypted reasoning include when reasoning is requested without an explicit include list', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
summary: 'auto',
},
},
'gpt-5',
false,
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
reasoning: {
effort: 'high',
summary: 'auto',
},
});
});
});
describe('convertOpenAiBodyToResponsesBody', () => {
it('maps OpenAI chat file blocks into Responses input_file blocks', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [
{
role: 'user',
content: [
{
type: 'file',
file: {
file_id: 'file-metapi-demo',
},
},
{
type: 'file',
file: {
filename: 'notes.md',
file_data: Buffer.from('# hello').toString('base64'),
},
},
],
},
],
},
'gpt-5',
false,
);
expect(result.input).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_file',
file_id: 'file-metapi-demo',
},
{
type: 'input_file',
filename: 'notes.md',
file_data: Buffer.from('# hello').toString('base64'),
},
],
},
]);
});
it('maps extra request fields and preserves custom/image_generation tools', () => {
const result = convertOpenAiBodyToResponsesBody(
{
@@ -223,9 +295,96 @@ describe('convertOpenAiBodyToResponsesBody', () => {
service_tier: 'flex',
});
});
it('maps OpenAI file-style content blocks into Responses input_file blocks', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this file' },
{
type: 'file',
file_id: 'file_local_123',
filename: 'report.pdf',
mime_type: 'application/pdf',
file_data: 'JVBERi0xLjQK',
},
],
},
],
},
'gpt-5',
false,
);
expect(result.input).toEqual([
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'summarize this file' },
{
type: 'input_file',
file_id: 'file_local_123',
filename: 'report.pdf',
mime_type: 'application/pdf',
file_data: 'JVBERi0xLjQK',
},
],
},
]);
});
});
describe('convertResponsesBodyToOpenAiBody', () => {
it('maps Responses input_file blocks back into OpenAI chat file blocks', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_file', file_id: 'file-metapi-demo' },
{
type: 'input_file',
filename: 'paper.pdf',
file_data: Buffer.from('%PDF-demo').toString('base64'),
},
],
},
],
},
'gpt-5',
false,
);
expect(result.messages).toEqual([
{
role: 'user',
content: [
{
type: 'file',
file: {
file_id: 'file-metapi-demo',
},
},
{
type: 'file',
file: {
filename: 'paper.pdf',
file_data: Buffer.from('%PDF-demo').toString('base64'),
},
},
],
},
]);
});
it('preserves richer Responses request fields back onto the OpenAI-compatible body', () => {
const result = convertResponsesBodyToOpenAiBody(
{
@@ -337,6 +496,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
type: 'reasoning',
id: 'rs_1',
status: 'completed',
encrypted_content: 'enc_sig_1',
summary: [
{ type: 'summary_text', text: 'Think step by step' },
],
@@ -356,6 +516,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
text: 'Think step by step',
},
],
reasoning_signature: 'enc_sig_1',
},
]);
});
@@ -371,6 +532,10 @@ describe('convertResponsesBodyToOpenAiBody', () => {
truncation: 'auto',
service_tier: 'priority',
top_logprobs: 4,
reasoning: {
effort: 'high',
summary: 'auto',
},
},
'gpt-5',
true,
@@ -385,6 +550,110 @@ describe('convertResponsesBodyToOpenAiBody', () => {
truncation: 'auto',
service_tier: 'priority',
top_logprobs: 4,
reasoning: {
effort: 'high',
summary: 'auto',
},
});
});
it('adds encrypted reasoning include for OpenAI-compatible fallback when reasoning options are present', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
},
'gpt-5',
true,
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
reasoning: {
effort: 'high',
},
});
});
it('keeps Responses input_file items when converting back to OpenAI-compatible bodies', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'analyze this upload' },
{
type: 'input_file',
file_id: 'file_local_456',
filename: 'notes.md',
mime_type: 'text/markdown',
file_data: 'IyBoZWxsbwo=',
},
],
},
],
},
'gpt-5',
false,
);
expect(result.messages).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'analyze this upload' },
{
type: 'file',
file: {
file_id: 'file_local_456',
filename: 'notes.md',
mime_type: 'text/markdown',
file_data: 'IyBoZWxsbwo=',
},
},
],
},
]);
});
it('keeps richer field parity on compatibility retry bodies when metadata is absent', () => {
const candidates = buildResponsesCompatibilityBodies({
model: 'gpt-5',
input: 'hello',
stream: true,
include: ['reasoning.encrypted_content'],
reasoning: {
effort: 'high',
summary: 'auto',
},
safety_identifier: 'safe-user-9',
max_tool_calls: 3,
prompt_cache_key: 'cache-key-9',
prompt_cache_retention: { scope: 'workspace' },
background: true,
top_logprobs: 2,
});
expect(candidates).toContainEqual({
model: 'gpt-5',
input: 'hello',
stream: true,
reasoning: {
effort: 'high',
summary: 'auto',
},
safety_identifier: 'safe-user-9',
max_tool_calls: 3,
prompt_cache_key: 'cache-key-9',
prompt_cache_retention: { scope: 'workspace' },
background: true,
top_logprobs: 2,
});
});
@@ -2,6 +2,7 @@ import {
normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility,
normalizeResponsesMessageItem,
} from './compatibility.js';
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
@@ -81,6 +82,32 @@ function normalizeIncludeList(value: unknown): unknown {
.filter((item) => item.length > 0);
}
function hasReasoningRequest(value: unknown): boolean {
if (!isRecord(value)) return false;
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 {
if (!hasReasoningRequest(body.reasoning)) return;
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;
}
body.include = [...includeList, 'reasoning.encrypted_content'];
}
function normalizeTextConfig(
rawText: unknown,
fallbackVerbosity?: unknown,
@@ -147,6 +174,7 @@ function normalizeResponsesRequestFieldParity(
if (normalized.include !== undefined) {
normalized.include = normalizeIncludeList(normalized.include);
}
ensureEncryptedReasoningInclude(normalized);
if (normalized.stream_options !== undefined) {
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
@@ -594,6 +622,11 @@ function normalizeOpenAiContentBlock(item: Record<string, unknown>): string | Re
};
}
if (type === 'input_file' || type === 'file') {
const fileBlock = normalizeInputFileBlock(item);
return fileBlock ? toOpenAiChatFileBlock(fileBlock) : null;
}
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
const text = extractTextContent(item).trim();
return text ? { type: 'text', text } : null;
@@ -738,15 +771,20 @@ export function convertResponsesBodyToOpenAiBody(
if (itemType === 'reasoning') {
flushPendingToolCalls();
const reasoningContent = toOpenAiMessageContent(item.summary ?? item.content ?? item);
const reasoningSignature = asTrimmedString(item.encrypted_content);
const hasReasoningContent = typeof reasoningContent === 'string'
? reasoningContent.trim().length > 0
: Array.isArray(reasoningContent) && reasoningContent.length > 0;
if (!hasReasoningContent) return;
if (!hasReasoningContent && !reasoningSignature) return;
messages.push({
const message: Record<string, unknown> = {
role: 'assistant',
content: reasoningContent,
});
};
if (reasoningSignature) {
message.reasoning_signature = reasoningSignature;
}
messages.push(message);
return;
}
@@ -813,6 +851,7 @@ export function convertResponsesBodyToOpenAiBody(
if (normalizedBody.include !== undefined) payload.include = cloneJsonValue(normalizedBody.include);
if (normalizedBody.previous_response_id !== undefined) payload.previous_response_id = normalizedBody.previous_response_id;
if (normalizedBody.truncation !== undefined) payload.truncation = normalizedBody.truncation;
if (normalizedBody.reasoning !== undefined) payload.reasoning = cloneJsonValue(normalizedBody.reasoning);
if (normalizedBody.service_tier !== undefined) payload.service_tier = normalizedBody.service_tier;
if (normalizedBody.top_logprobs !== undefined) payload.top_logprobs = normalizedBody.top_logprobs;
if (normalizedBody.stream_options !== undefined) payload.stream_options = normalizedBody.stream_options;
@@ -248,4 +248,77 @@ describe('serializeResponsesFinalPayload', () => {
},
});
});
it('restores encrypted reasoning content from provider-tagged reasoning signatures', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'chatcmpl_reasoning',
model: 'gpt-5',
},
normalized: {
id: 'chatcmpl_reasoning',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: 'Think step by step',
reasoningSignature: 'metapi:openai-encrypted-reasoning:enc-sig-1',
finishReason: 'stop',
toolCalls: [],
} as any,
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'msg_chatcmpl_reasoning_reasoning',
type: 'reasoning',
status: 'completed',
encrypted_content: 'enc-sig-1',
summary: [
{
type: 'summary_text',
text: 'Think step by step',
},
],
},
]);
});
it('emits encrypted-only reasoning items when summary text is empty', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'chatcmpl_reasoning_only',
model: 'gpt-5',
},
normalized: {
id: 'chatcmpl_reasoning_only',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: '',
reasoningSignature: 'metapi:openai-encrypted-reasoning:enc-only-1',
finishReason: 'stop',
toolCalls: [],
} as any,
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'msg_chatcmpl_reasoning_only_reasoning',
type: 'reasoning',
status: 'completed',
encrypted_content: 'enc-only-1',
summary: [],
},
]);
});
});
@@ -2,6 +2,7 @@ import {
normalizeUpstreamFinalResponse,
type NormalizedFinalResponse,
} from '../../shared/normalized.js';
import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
@@ -301,6 +302,11 @@ export function serializeResponsesFinalPayload(input: {
if (extracted.length > 0) return extracted;
return extractToolCallsFromNormalized(normalized);
})();
const rawReasoningSignature = typeof normalized.reasoningSignature === 'string'
? normalized.reasoningSignature.trim()
: '';
const encryptedReasoning = decodeOpenAiEncryptedReasoning(rawReasoningSignature)
?? (rawReasoningSignature && !rawReasoningSignature.startsWith('metapi:') ? rawReasoningSignature : null);
const annotations = extractAnnotationsFromUpstream(upstreamPayload);
const output: Array<Record<string, unknown>> = syntheticOutput.map((item) => cloneJson(item));
@@ -317,16 +323,22 @@ export function serializeResponsesFinalPayload(input: {
);
});
if (normalized.reasoningContent && !hasReasoningItem) {
output.push({
if ((normalized.reasoningContent || encryptedReasoning) && !hasReasoningItem) {
const reasoningItem: Record<string, unknown> = {
id: ensureMessageId(`${normalizedId}_reasoning`),
type: 'reasoning',
status: 'completed',
summary: [{
type: 'summary_text',
text: normalized.reasoningContent,
}],
});
summary: normalized.reasoningContent
? [{
type: 'summary_text',
text: normalized.reasoningContent,
}]
: [],
};
if (encryptedReasoning) {
reasoningItem.encrypted_content = encryptedReasoning;
}
output.push(reasoningItem);
}
if ((normalized.content || (!hasToolLikeItem && output.length === 0 && toolCalls.length === 0)) && !hasMessageItem) {