forked from boos/BoosAPI
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:
@@ -151,6 +151,50 @@ describe('sanitizeAnthropicMessagesBody', () => {
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
it('maps OpenAI file blocks into Anthropic document blocks', () => {
|
||||
const base64Pdf = Buffer.from('%PDF-hello').toString('base64');
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'paper.pdf',
|
||||
file_data: base64Pdf,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: base64Pdf,
|
||||
},
|
||||
title: 'paper.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves image blocks, tool_use blocks, and tool_result chains', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
@@ -238,6 +282,82 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops thinking blocks whose signatures belong to another provider', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'metapi:openai-encrypted-reasoning:enc-foreign',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'final answer',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes anthropic-tagged thinking signatures before sending upstream', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'metapi:anthropic-signature:sig-anthropic-1',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'sig-anthropic-1',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips cache_control from thinking blocks and empty text blocks', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
@@ -410,7 +530,7 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
expect(body.output_config).toEqual({ effort: 'high' });
|
||||
});
|
||||
|
||||
it('rebuilds cache_control anchors at the anthropic inbound boundary', () => {
|
||||
it('keeps inbound claude bodies thin instead of rebuilding cache anchors', () => {
|
||||
const result = anthropicMessagesInbound.parse(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
@@ -431,36 +551,22 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody?.system).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'system prompt',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]);
|
||||
expect(result.value?.claudeOriginalBody?.tools).toEqual([
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]);
|
||||
expect(result.value?.claudeOriginalBody?.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(result.value?.claudeOriginalBody).toEqual({
|
||||
model: 'gpt-5',
|
||||
max_tokens: 512,
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
system: 'system prompt',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { normalizeInputFileBlock, toAnthropicDocumentBlock } from '../../shared/inputFile.js';
|
||||
import {
|
||||
decodeAnthropicReasoningSignature,
|
||||
} from '../../shared/reasoningTransport.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
@@ -256,6 +261,19 @@ function toAnthropicTextBlock(text: string): Record<string, unknown> | null {
|
||||
return normalized ? { type: 'text', text: normalized } : null;
|
||||
}
|
||||
|
||||
function resolveAnthropicThinkingSignature(item: Record<string, unknown>): string | null | undefined {
|
||||
const rawSignature = asTrimmedString(item.signature ?? item.reasoning_signature);
|
||||
if (!rawSignature) return undefined;
|
||||
const decodedTaggedSignature = decodeAnthropicReasoningSignature(rawSignature);
|
||||
if (decodedTaggedSignature !== null) {
|
||||
return decodedTaggedSignature;
|
||||
}
|
||||
if (item.reasoning_signature !== undefined || rawSignature.startsWith('metapi:')) {
|
||||
return null;
|
||||
}
|
||||
return rawSignature;
|
||||
}
|
||||
|
||||
function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!type) {
|
||||
@@ -276,9 +294,16 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
return toAnthropicImageBlock(item);
|
||||
}
|
||||
|
||||
if (type === 'file' || type === 'input_file') {
|
||||
const fileBlock = normalizeInputFileBlock(item);
|
||||
return fileBlock ? toAnthropicDocumentBlock(fileBlock) : null;
|
||||
}
|
||||
|
||||
if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') {
|
||||
const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data);
|
||||
if (!text) return null;
|
||||
const signature = type === 'redacted_thinking' ? undefined : resolveAnthropicThinkingSignature(item);
|
||||
if (signature === null) return null;
|
||||
const next: Record<string, unknown> = { ...item };
|
||||
if (type === 'redacted_thinking') {
|
||||
next.type = 'redacted_thinking';
|
||||
@@ -287,10 +312,16 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
} else {
|
||||
next.type = type === 'reasoning' ? 'thinking' : type;
|
||||
next.thinking = text;
|
||||
if (signature) {
|
||||
next.signature = signature;
|
||||
} else {
|
||||
delete next.signature;
|
||||
}
|
||||
}
|
||||
delete next.cache_control;
|
||||
delete next.text;
|
||||
delete next.content;
|
||||
delete next.reasoning_signature;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -893,6 +924,7 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
|
||||
|
||||
export function validateAnthropicMessagesBody(
|
||||
body: Record<string, unknown>,
|
||||
options: { autoOptimizeCacheControls?: boolean } = {},
|
||||
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
|
||||
const thinkingResult = sanitizeAnthropicThinkingConfig(body.thinking);
|
||||
if (thinkingResult.error) {
|
||||
@@ -928,5 +960,5 @@ export function validateAnthropicMessagesBody(
|
||||
};
|
||||
}
|
||||
|
||||
return { sanitizedBody: sanitizeAnthropicMessagesBody(body) };
|
||||
return { sanitizedBody: sanitizeAnthropicMessagesBody(body, options) };
|
||||
}
|
||||
|
||||
@@ -60,4 +60,97 @@ describe('anthropicMessagesInbound', () => {
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves already-native anthropic block shapes and cache markers', () => {
|
||||
const result = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
system: [
|
||||
{ type: 'text', text: 'system prompt', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'high' },
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
system: [
|
||||
{ type: 'text', text: 'system prompt', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'high' },
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps native cache_control placement for already-native anthropic bodies', () => {
|
||||
const nativeBody = {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool_1',
|
||||
name: 'lookup',
|
||||
input: { city: 'paris' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
};
|
||||
|
||||
const result = anthropicMessagesInbound.parse(nativeBody);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual(nativeBody);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,9 @@ function sanitizeAnthropicInboundBody(
|
||||
const toolChoiceError = validateToolChoice(body);
|
||||
if (toolChoiceError) return { error: toolChoiceError };
|
||||
|
||||
const validation = validateAnthropicMessagesBody(body);
|
||||
const validation = validateAnthropicMessagesBody(body, {
|
||||
autoOptimizeCacheControls: false,
|
||||
});
|
||||
if (validation.error) {
|
||||
return { error: validation.error };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ describe('extractAnthropicUsage', () => {
|
||||
completionTokens: 30,
|
||||
totalTokens: 130,
|
||||
cachedTokens: 12,
|
||||
cacheReadTokens: 12,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
|
||||
@@ -256,6 +256,8 @@ export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
|
||||
completionTokens: summary.completionTokens,
|
||||
totalTokens: summary.totalTokens,
|
||||
cachedTokens: summary.cacheReadInputTokens,
|
||||
cacheReadTokens: summary.cacheReadInputTokens,
|
||||
cacheCreationTokens: summary.cacheCreationInputTokens,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
|
||||
@@ -523,6 +523,8 @@ describe('Gemini aggregate/outbound/usage', () => {
|
||||
completionTokens: 11,
|
||||
totalTokens: 31,
|
||||
cachedTokens: 4,
|
||||
cacheReadTokens: 4,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 4,
|
||||
});
|
||||
expect(extractGeminiUsage(serialized)).toEqual({
|
||||
@@ -530,6 +532,8 @@ describe('Gemini aggregate/outbound/usage', () => {
|
||||
completionTokens: 11,
|
||||
totalTokens: 31,
|
||||
cachedTokens: 4,
|
||||
cacheReadTokens: 4,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 4,
|
||||
});
|
||||
});
|
||||
@@ -550,6 +554,8 @@ describe('Gemini aggregate/outbound/usage', () => {
|
||||
completionTokens: 6,
|
||||
totalTokens: 6,
|
||||
cachedTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -570,6 +570,8 @@ describe('extractGeminiUsage', () => {
|
||||
completionTokens: 10,
|
||||
totalTokens: 18,
|
||||
cachedTokens: 5,
|
||||
cacheReadTokens: 5,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 3,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@ function baseIncludesVersion(baseUrl: string): boolean {
|
||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (baseIncludesVersion(normalized)) return normalized;
|
||||
@@ -37,6 +41,42 @@ export function resolveGeminiGenerateContentUrl(
|
||||
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string {
|
||||
return asTrimmedString(params?.geminiApiVersion) || 'v1beta';
|
||||
}
|
||||
|
||||
export function parseGeminiProxyRequestPath(input: {
|
||||
rawUrl?: string | null;
|
||||
params?: { geminiApiVersion?: unknown } | null;
|
||||
}): {
|
||||
apiVersion: string;
|
||||
modelActionPath: string;
|
||||
requestedModel: string;
|
||||
isStreamAction: boolean;
|
||||
} {
|
||||
const apiVersion = resolveGeminiProxyApiVersion(input.params);
|
||||
const rawUrl = asTrimmedString(input.rawUrl);
|
||||
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
|
||||
const normalizedVersion = apiVersion.replace(/^\/+/, '');
|
||||
const geminiPrefix = `/gemini/${normalizedVersion}/`;
|
||||
const aliasPrefix = `/${normalizedVersion}/`;
|
||||
|
||||
let modelActionPath = withoutQuery.replace(/^\/+/, '');
|
||||
if (withoutQuery.startsWith(geminiPrefix)) {
|
||||
modelActionPath = withoutQuery.slice(geminiPrefix.length);
|
||||
} else if (withoutQuery.startsWith(aliasPrefix)) {
|
||||
modelActionPath = withoutQuery.slice(aliasPrefix.length);
|
||||
}
|
||||
|
||||
const requestedModel = modelActionPath.replace(/^models\//, '').split(':')[0].trim();
|
||||
return {
|
||||
apiVersion,
|
||||
modelActionPath,
|
||||
requestedModel,
|
||||
isStreamAction: modelActionPath.endsWith(':streamGenerateContent'),
|
||||
};
|
||||
}
|
||||
|
||||
import { geminiGenerateContentInbound } from './inbound.js';
|
||||
import { geminiGenerateContentOutbound } from './outbound.js';
|
||||
import { geminiGenerateContentStream } from './stream.js';
|
||||
@@ -58,6 +98,8 @@ export const geminiGenerateContentTransformer = {
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
geminiThinkingConfigToReasoning,
|
||||
},
|
||||
parseProxyRequestPath: parseGeminiProxyRequestPath,
|
||||
resolveProxyApiVersion: resolveGeminiProxyApiVersion,
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
|
||||
@@ -37,6 +37,8 @@ export function extractGeminiUsage(payload: unknown): NormalizedUsage {
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
cachedTokens,
|
||||
cacheReadTokens: cachedTokens,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
decodeAnthropicReasoningSignature,
|
||||
} from './reasoningTransport.js';
|
||||
|
||||
export type DownstreamFormat = 'openai' | 'claude';
|
||||
|
||||
export type ParsedSseEvent = {
|
||||
@@ -32,6 +36,8 @@ export type NormalizedStreamEvent = {
|
||||
role?: 'assistant';
|
||||
contentDelta?: string;
|
||||
reasoningDelta?: string;
|
||||
reasoningSignature?: string;
|
||||
redactedReasoningContent?: string;
|
||||
toolCallDeltas?: Array<{
|
||||
index: number;
|
||||
id?: string;
|
||||
@@ -48,6 +54,8 @@ export type NormalizedFinalResponse = {
|
||||
created: number;
|
||||
content: string;
|
||||
reasoningContent: string;
|
||||
reasoningSignature?: string;
|
||||
redactedReasoningContent?: string;
|
||||
finishReason: string;
|
||||
toolCalls: Array<{
|
||||
id: string;
|
||||
@@ -746,6 +754,9 @@ export function normalizeUpstreamStreamEvent(
|
||||
|| (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '')
|
||||
|| deltaParsed.reasoning
|
||||
|| '';
|
||||
const reasoningSignature = isNonEmptyString((delta as any).reasoning_signature)
|
||||
? (delta as any).reasoning_signature
|
||||
: undefined;
|
||||
|
||||
// Some upstream providers (e.g. certain OpenAI-compatible aggregators) emit thinking
|
||||
// tokens with the same text duplicated in both delta.content and delta.reasoning_content.
|
||||
@@ -791,6 +802,7 @@ export function normalizeUpstreamStreamEvent(
|
||||
role: (delta as any).role === 'assistant' ? 'assistant' : undefined,
|
||||
contentDelta: contentDelta || undefined,
|
||||
reasoningDelta: reasoningDelta || undefined,
|
||||
reasoningSignature,
|
||||
toolCallDeltas: toolCallDeltas.length > 0 ? toolCallDeltas : undefined,
|
||||
finishReason: normalizeStopReason(choice?.finish_reason),
|
||||
};
|
||||
@@ -830,6 +842,11 @@ export function normalizeUpstreamStreamEvent(
|
||||
|
||||
if (type === 'response.output_item.added' && isRecord((payload as any).item)) {
|
||||
const item = (payload as any).item as Record<string, unknown>;
|
||||
if (item.type === 'reasoning' && isNonEmptyString(item.encrypted_content)) {
|
||||
return {
|
||||
reasoningSignature: item.encrypted_content,
|
||||
};
|
||||
}
|
||||
if (item.type === 'function_call') {
|
||||
const outputIndex = (
|
||||
typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index)
|
||||
@@ -1371,13 +1388,29 @@ export function serializeFinalResponse(
|
||||
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
): Record<string, unknown> {
|
||||
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
|
||||
const rawReasoningSignature = typeof normalized.reasoningSignature === 'string'
|
||||
? normalized.reasoningSignature.trim()
|
||||
: '';
|
||||
const taggedReasoningSignature = rawReasoningSignature.startsWith('metapi:');
|
||||
const claudeReasoningSignature = decodeAnthropicReasoningSignature(rawReasoningSignature)
|
||||
?? (rawReasoningSignature && !taggedReasoningSignature ? rawReasoningSignature : null);
|
||||
|
||||
if (downstreamFormat === 'claude') {
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
if (normalized.reasoningContent) {
|
||||
contentBlocks.push({
|
||||
const thinkingBlock: Record<string, unknown> = {
|
||||
type: 'thinking',
|
||||
thinking: normalized.reasoningContent,
|
||||
};
|
||||
if (claudeReasoningSignature) {
|
||||
thinkingBlock.signature = claudeReasoningSignature;
|
||||
}
|
||||
contentBlocks.push(thinkingBlock);
|
||||
}
|
||||
if (normalized.redactedReasoningContent) {
|
||||
contentBlocks.push({
|
||||
type: 'redacted_thinking',
|
||||
data: normalized.redactedReasoningContent,
|
||||
});
|
||||
}
|
||||
if (normalized.content) {
|
||||
@@ -1422,6 +1455,9 @@ export function serializeFinalResponse(
|
||||
if (normalized.reasoningContent) {
|
||||
message.reasoning_content = normalized.reasoningContent;
|
||||
}
|
||||
if (rawReasoningSignature) {
|
||||
message.reasoning_signature = rawReasoningSignature;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toOpenAiToolCalls(toolCalls);
|
||||
if (!normalized.content) message.content = '';
|
||||
@@ -1470,6 +1506,9 @@ export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse):
|
||||
if (normalized.reasoningContent) {
|
||||
startDelta.reasoning_content = normalized.reasoningContent;
|
||||
}
|
||||
if (typeof normalized.reasoningSignature === 'string' && normalized.reasoningSignature.trim()) {
|
||||
startDelta.reasoning_signature = normalized.reasoningSignature.trim();
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
startDelta.tool_calls = toOpenAiToolCalls(toolCalls);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function splitBase64DataUrl(value: string): { mimeType: string; data: string } | null {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1].trim().toLowerCase(),
|
||||
data: match[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export type NormalizedInputFile = {
|
||||
sourceType?: 'file' | 'input_file';
|
||||
fileId?: string;
|
||||
fileData?: string;
|
||||
filename?: string;
|
||||
mimeType?: string | null;
|
||||
hadDataUrl?: boolean;
|
||||
};
|
||||
|
||||
export function inferInputFileMimeType(input: Pick<NormalizedInputFile, 'filename' | 'mimeType'>): string | null {
|
||||
const explicit = asTrimmedString(input.mimeType);
|
||||
if (explicit) return explicit.toLowerCase();
|
||||
|
||||
const filename = asTrimmedString(input.filename).toLowerCase();
|
||||
if (!filename) return null;
|
||||
if (filename.endsWith('.pdf')) return 'application/pdf';
|
||||
if (filename.endsWith('.txt')) return 'text/plain';
|
||||
if (filename.endsWith('.md') || filename.endsWith('.markdown')) return 'text/markdown';
|
||||
if (filename.endsWith('.json')) return 'application/json';
|
||||
if (filename.endsWith('.png')) return 'image/png';
|
||||
if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (filename.endsWith('.gif')) return 'image/gif';
|
||||
if (filename.endsWith('.webp')) return 'image/webp';
|
||||
if (filename.endsWith('.wav')) return 'audio/wav';
|
||||
if (filename.endsWith('.mp3')) return 'audio/mpeg';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeInputFileBlock(item: Record<string, unknown>): NormalizedInputFile | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type === 'input_file') {
|
||||
const fileId = asTrimmedString(item.file_id);
|
||||
const fileData = asTrimmedString(item.file_data);
|
||||
const filename = asTrimmedString(item.filename);
|
||||
let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null;
|
||||
if (!fileId && !fileData) return null;
|
||||
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
|
||||
if (parsedDataUrl) {
|
||||
mimeType = mimeType || parsedDataUrl.mimeType;
|
||||
}
|
||||
return {
|
||||
sourceType: 'input_file',
|
||||
fileId: fileId || undefined,
|
||||
fileData: fileData || undefined,
|
||||
filename: filename || undefined,
|
||||
mimeType,
|
||||
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'file') {
|
||||
const file = isRecord(item.file) ? item.file : item;
|
||||
const fileId = asTrimmedString(file.file_id ?? item.file_id);
|
||||
const fileData = asTrimmedString(file.file_data ?? item.file_data);
|
||||
const filename = asTrimmedString(file.filename ?? item.filename);
|
||||
let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null;
|
||||
if (!fileId && !fileData) return null;
|
||||
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
|
||||
if (parsedDataUrl) {
|
||||
mimeType = mimeType || parsedDataUrl.mimeType;
|
||||
}
|
||||
return {
|
||||
sourceType: 'file',
|
||||
fileId: fileId || undefined,
|
||||
fileData: fileData || undefined,
|
||||
filename: filename || undefined,
|
||||
mimeType,
|
||||
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toResponsesInputFileBlock(file: NormalizedInputFile): Record<string, unknown> {
|
||||
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
|
||||
const block: Record<string, unknown> = { type: 'input_file' };
|
||||
if (file.fileId) block.file_id = file.fileId;
|
||||
if (file.fileData) block.file_data = parsedDataUrl?.data || file.fileData;
|
||||
if (file.filename) block.filename = file.filename;
|
||||
if (file.mimeType) block.mime_type = file.mimeType;
|
||||
else if (parsedDataUrl?.mimeType) block.mime_type = parsedDataUrl.mimeType;
|
||||
return block;
|
||||
}
|
||||
|
||||
export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record<string, unknown> {
|
||||
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (file.fileId) payload.file_id = file.fileId;
|
||||
if (file.fileData) payload.file_data = parsedDataUrl?.data || file.fileData;
|
||||
if (file.filename) payload.filename = file.filename;
|
||||
if (file.mimeType) payload.mime_type = file.mimeType;
|
||||
else if (parsedDataUrl?.mimeType) payload.mime_type = parsedDataUrl.mimeType;
|
||||
const block: Record<string, unknown> = { type: 'file' };
|
||||
block.file = payload;
|
||||
return block;
|
||||
}
|
||||
|
||||
export function toAnthropicDocumentBlock(file: NormalizedInputFile): Record<string, unknown> | null {
|
||||
if (!file.fileData) return null;
|
||||
const parsedDataUrl = splitBase64DataUrl(file.fileData);
|
||||
const mimeType = parsedDataUrl?.mimeType || inferInputFileMimeType(file);
|
||||
if (!mimeType) return null;
|
||||
return {
|
||||
type: 'document',
|
||||
...(file.hadDataUrl ? { cache_control: { type: 'ephemeral' } } : {}),
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mimeType,
|
||||
data: parsedDataUrl?.data || file.fileData,
|
||||
},
|
||||
...(file.filename ? { title: file.filename } : {}),
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,14 @@ describe('shared normalized helpers', () => {
|
||||
expect(source).not.toContain('chatFormats.js');
|
||||
});
|
||||
|
||||
it('exposes shared transformer metadata extensions', () => {
|
||||
const source = readFileSync(new URL('./normalized.ts', import.meta.url), 'utf8');
|
||||
expect(source).toContain('thoughtSignatures');
|
||||
expect(source).toContain('promptCacheKey');
|
||||
expect(source).toContain('truncation');
|
||||
expect(source).toContain('serviceTier');
|
||||
});
|
||||
|
||||
it('parses SSE events and keeps the trailing partial block', () => {
|
||||
const pulled = pullSseEventsWithDone([
|
||||
'event: message',
|
||||
@@ -68,14 +76,19 @@ describe('shared normalized helpers', () => {
|
||||
});
|
||||
|
||||
it('serializes normalized final responses for claude', () => {
|
||||
const normalized: NormalizedFinalResponse = {
|
||||
const normalized = {
|
||||
id: 'chatcmpl-1',
|
||||
model: 'claude-test',
|
||||
created: 456,
|
||||
content: 'done',
|
||||
reasoningContent: 'thinking',
|
||||
reasoningSignature: 'metapi:anthropic-signature:sig-1',
|
||||
redactedReasoningContent: 'ciphertext',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'tool_1', name: 'lookup', arguments: '{"q":"x"}' }],
|
||||
} as NormalizedFinalResponse & {
|
||||
reasoningSignature: string;
|
||||
redactedReasoningContent: string;
|
||||
};
|
||||
|
||||
expect(serializeFinalResponse('claude', normalized, {
|
||||
@@ -88,7 +101,8 @@ describe('shared normalized helpers', () => {
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'thinking' },
|
||||
{ type: 'thinking', thinking: 'thinking', signature: 'sig-1' },
|
||||
{ type: 'redacted_thinking', data: 'ciphertext' },
|
||||
{ type: 'text', text: 'done' },
|
||||
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { q: 'x' } },
|
||||
],
|
||||
@@ -101,6 +115,34 @@ describe('shared normalized helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes provider-tagged reasoning signatures for openai-compatible downstreams', () => {
|
||||
const normalized = {
|
||||
id: 'chatcmpl-2',
|
||||
model: 'gpt-test',
|
||||
created: 789,
|
||||
content: 'final',
|
||||
reasoningContent: 'deliberation',
|
||||
reasoningSignature: 'metapi:openai-encrypted-reasoning:enc-1',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
} as NormalizedFinalResponse & { reasoningSignature: string };
|
||||
|
||||
expect(serializeFinalResponse('openai', normalized, {
|
||||
promptTokens: 3,
|
||||
completionTokens: 5,
|
||||
totalTokens: 8,
|
||||
})).toMatchObject({
|
||||
choices: [{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'final',
|
||||
reasoning_content: 'deliberation',
|
||||
reasoning_signature: 'metapi:openai-encrypted-reasoning:enc-1',
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes known stop reasons', () => {
|
||||
expect(normalizeStopReason('max_output_tokens')).toBe('length');
|
||||
expect(normalizeStopReason('tool_use')).toBe('tool_calls');
|
||||
|
||||
@@ -24,6 +24,7 @@ export type NormalizedContentBlockType =
|
||||
| 'text'
|
||||
| 'image_url'
|
||||
| 'image_inline'
|
||||
| 'input_file'
|
||||
| 'input_audio'
|
||||
| 'output_audio'
|
||||
| 'tool_call'
|
||||
@@ -39,6 +40,8 @@ export type NormalizedContentBlock = {
|
||||
mimeType?: string | null;
|
||||
url?: string | null;
|
||||
data?: string | null;
|
||||
fileId?: string | null;
|
||||
filename?: string | null;
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
argumentsText?: string | null;
|
||||
@@ -51,6 +54,8 @@ export type NormalizedUsage = {
|
||||
completionTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
cachedTokens?: number | null;
|
||||
cacheReadTokens?: number | null;
|
||||
cacheCreationTokens?: number | null;
|
||||
reasoningTokens?: number | null;
|
||||
audioInputTokens?: number | null;
|
||||
audioOutputTokens?: number | null;
|
||||
@@ -66,13 +71,17 @@ export type ParsedDownstreamChatRequestResult = {
|
||||
export type TransformerMetadata = {
|
||||
include?: unknown;
|
||||
maxToolCalls?: number | null;
|
||||
promptCacheKey?: unknown;
|
||||
promptCacheRetention?: unknown;
|
||||
truncation?: unknown;
|
||||
serviceTier?: unknown;
|
||||
includeObfuscation?: boolean | null;
|
||||
citations?: unknown;
|
||||
annotations?: unknown;
|
||||
geminiSafetySettings?: unknown;
|
||||
geminiImageConfig?: unknown;
|
||||
thoughtSignature?: string | null;
|
||||
thoughtSignatures?: string[];
|
||||
passthrough?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -108,6 +117,8 @@ export function createEmptyNormalizedUsage(): NormalizedUsage {
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
@@ -127,6 +138,8 @@ export function mergeNormalizedUsage(
|
||||
completionTokens: (merged.completionTokens || 0) + (incoming.completionTokens || 0),
|
||||
totalTokens: (merged.totalTokens || 0) + (incoming.totalTokens || 0),
|
||||
cachedTokens: (merged.cachedTokens || 0) + (incoming.cachedTokens || 0),
|
||||
cacheReadTokens: (merged.cacheReadTokens || 0) + (incoming.cacheReadTokens || 0),
|
||||
cacheCreationTokens: (merged.cacheCreationTokens || 0) + (incoming.cacheCreationTokens || 0),
|
||||
reasoningTokens: (merged.reasoningTokens || 0) + (incoming.reasoningTokens || 0),
|
||||
audioInputTokens: (merged.audioInputTokens || 0) + (incoming.audioInputTokens || 0),
|
||||
audioOutputTokens: (merged.audioOutputTokens || 0) + (incoming.audioOutputTokens || 0),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decodeAnthropicReasoningSignature,
|
||||
decodeGeminiThoughtSignature,
|
||||
decodeOpenAiEncryptedReasoning,
|
||||
encodeAnthropicReasoningSignature,
|
||||
encodeGeminiThoughtSignature,
|
||||
encodeOpenAiEncryptedReasoning,
|
||||
isAnthropicReasoningSignature,
|
||||
isGeminiThoughtSignature,
|
||||
isOpenAiEncryptedReasoning,
|
||||
isSafeAnthropicRedactedReasoningCarrier,
|
||||
} from './reasoningTransport.js';
|
||||
|
||||
describe('reasoning transport codecs', () => {
|
||||
it('round-trips provider-tagged reasoning signatures', () => {
|
||||
const anthropic = encodeAnthropicReasoningSignature('anthropic-sig');
|
||||
const gemini = encodeGeminiThoughtSignature('gemini-sig');
|
||||
const openai = encodeOpenAiEncryptedReasoning('openai-enc');
|
||||
|
||||
expect(isAnthropicReasoningSignature(anthropic)).toBe(true);
|
||||
expect(isGeminiThoughtSignature(gemini)).toBe(true);
|
||||
expect(isOpenAiEncryptedReasoning(openai)).toBe(true);
|
||||
|
||||
expect(decodeAnthropicReasoningSignature(anthropic)).toBe('anthropic-sig');
|
||||
expect(decodeGeminiThoughtSignature(gemini)).toBe('gemini-sig');
|
||||
expect(decodeOpenAiEncryptedReasoning(openai)).toBe('openai-enc');
|
||||
});
|
||||
|
||||
it('prevents prefixed signatures from being treated as anthropic redacted reasoning carriers', () => {
|
||||
expect(isSafeAnthropicRedactedReasoningCarrier('ciphertext')).toBe(true);
|
||||
expect(isSafeAnthropicRedactedReasoningCarrier(encodeAnthropicReasoningSignature('sig'))).toBe(false);
|
||||
expect(isSafeAnthropicRedactedReasoningCarrier(encodeGeminiThoughtSignature('sig'))).toBe(false);
|
||||
expect(isSafeAnthropicRedactedReasoningCarrier(encodeOpenAiEncryptedReasoning('enc'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const PROVIDER_PREFIX = 'metapi';
|
||||
|
||||
export const ANTHROPIC_REASONING_SIGNATURE_PREFIX = `${PROVIDER_PREFIX}:anthropic-signature:`;
|
||||
export const GEMINI_THOUGHT_SIGNATURE_PREFIX = `${PROVIDER_PREFIX}:gemini-thought-signature:`;
|
||||
export const OPENAI_ENCRYPTED_REASONING_PREFIX = `${PROVIDER_PREFIX}:openai-encrypted-reasoning:`;
|
||||
|
||||
function stripPrefix(value: string, prefix: string): string | null {
|
||||
return value.startsWith(prefix) ? value.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
export function isAnthropicReasoningSignature(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith(ANTHROPIC_REASONING_SIGNATURE_PREFIX);
|
||||
}
|
||||
|
||||
export function encodeAnthropicReasoningSignature(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return isAnthropicReasoningSignature(signature) ? signature : `${ANTHROPIC_REASONING_SIGNATURE_PREFIX}${signature}`;
|
||||
}
|
||||
|
||||
export function decodeAnthropicReasoningSignature(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return stripPrefix(signature, ANTHROPIC_REASONING_SIGNATURE_PREFIX);
|
||||
}
|
||||
|
||||
export function isGeminiThoughtSignature(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith(GEMINI_THOUGHT_SIGNATURE_PREFIX);
|
||||
}
|
||||
|
||||
export function encodeGeminiThoughtSignature(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return isGeminiThoughtSignature(signature) ? signature : `${GEMINI_THOUGHT_SIGNATURE_PREFIX}${signature}`;
|
||||
}
|
||||
|
||||
export function decodeGeminiThoughtSignature(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return stripPrefix(signature, GEMINI_THOUGHT_SIGNATURE_PREFIX);
|
||||
}
|
||||
|
||||
export function isOpenAiEncryptedReasoning(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith(OPENAI_ENCRYPTED_REASONING_PREFIX);
|
||||
}
|
||||
|
||||
export function encodeOpenAiEncryptedReasoning(encryptedContent: string | null | undefined): string | null {
|
||||
if (!encryptedContent) return null;
|
||||
return isOpenAiEncryptedReasoning(encryptedContent)
|
||||
? encryptedContent
|
||||
: `${OPENAI_ENCRYPTED_REASONING_PREFIX}${encryptedContent}`;
|
||||
}
|
||||
|
||||
export function decodeOpenAiEncryptedReasoning(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return stripPrefix(signature, OPENAI_ENCRYPTED_REASONING_PREFIX);
|
||||
}
|
||||
|
||||
export function isSafeAnthropicRedactedReasoningCarrier(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& !isAnthropicReasoningSignature(value)
|
||||
&& !isGeminiThoughtSignature(value)
|
||||
&& !isOpenAiEncryptedReasoning(value);
|
||||
}
|
||||
Reference in New Issue
Block a user