Files
metapi/src/server/transformers/openai/responses/outbound.test.ts
T
Cita 5be5e2eb07 fix: preserve protocol parity across Claude fallbacks (#256)
* fix: preserve protocol parity across claude fallbacks

* fix: tighten protocol parity across responses and anthropic fallbacks

* fix: preserve stricter tool contracts across fallbacks

* fix: unblock anthropic stream server build
2026-03-23 17:29:49 +08:00

517 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { serializeResponsesFinalPayload } from './outbound.js';
describe('serializeResponsesFinalPayload', () => {
it('preserves native response.compaction payloads when compact serialization is requested', () => {
const upstreamPayload = {
id: 'cmp_123',
object: 'response.compaction',
created_at: 1700000000,
output: [
{
id: 'rs_123',
type: 'compaction',
encrypted_content: 'enc-compact-payload',
},
],
usage: {
input_tokens: 1234,
output_tokens: 321,
total_tokens: 1555,
},
};
const payload = serializeResponsesFinalPayload({
upstreamPayload,
normalized: {
id: 'cmp_123',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 1234,
completionTokens: 321,
totalTokens: 1555,
},
serializationMode: 'compact',
} as any);
expect(payload).toEqual(upstreamPayload);
});
it('serializes compact mode as response.compaction instead of an ordinary response object', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'chatcmpl_compact',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-5',
choices: [
{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
},
},
],
},
normalized: {
id: 'chatcmpl_compact',
model: 'gpt-5',
created: 1700000000,
content: 'hello',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 11,
completionTokens: 7,
totalTokens: 18,
},
serializationMode: 'compact',
} as any);
expect(payload).toEqual({
id: 'resp_chatcmpl_compact',
object: 'response.compaction',
created_at: 1700000000,
output: [
{
id: 'msg_chatcmpl_compact',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'hello',
},
],
},
],
usage: {
input_tokens: 11,
output_tokens: 7,
total_tokens: 18,
},
});
expect(payload).not.toHaveProperty('created');
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('model');
expect(payload).not.toHaveProperty('output_text');
});
it('serializes response mode with created_at instead of created', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'chatcmpl_response_mode',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-5',
choices: [
{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
},
},
],
},
normalized: {
id: 'chatcmpl_response_mode',
model: 'gpt-5',
created: 1700000000,
content: 'hello',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 11,
completionTokens: 7,
totalTokens: 18,
},
} as any);
expect(payload).toMatchObject({
object: 'response',
created_at: 1700000000,
});
expect(payload).not.toHaveProperty('created');
});
it('preserves top-level chat annotations on synthetic assistant messages', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'chatcmpl_1',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-5',
choices: [
{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
annotations: [
{
type: 'url_citation',
url: 'https://example.com',
title: 'Example',
},
],
},
},
],
},
normalized: {
id: 'chatcmpl_1',
model: 'gpt-5',
created: 1700000000,
content: 'hello',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'msg_chatcmpl_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'hello',
annotations: [
{
type: 'url_citation',
url: 'https://example.com',
title: 'Example',
},
],
},
],
},
]);
});
it('falls back to normalized tool calls when upstream payload no longer exposes them directly', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'opaque_1',
model: 'gpt-5',
},
normalized: {
id: 'opaque_1',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: '',
finishReason: 'tool_calls',
toolCalls: [
{
id: 'call_1',
name: 'browser',
arguments: '{"url":"https://example.com"}',
},
],
},
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'call_1',
type: 'function_call',
status: 'completed',
call_id: 'call_1',
name: 'browser',
arguments: '{"url":"https://example.com"}',
},
]);
});
it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'resp_like_1',
model: 'gpt-5',
output: [
{
id: 'ct_1',
type: 'custom_tool_call',
status: 'completed',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
{
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
background: 'transparent',
output_format: 'png',
quality: 'high',
size: '1024x1024',
partial_images: [
{
partial_image_index: 0,
partial_image_b64: 'partial',
},
],
},
],
},
normalized: {
id: 'resp_like_1',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'ct_1',
type: 'custom_tool_call',
status: 'completed',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
{
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
background: 'transparent',
output_format: 'png',
quality: 'high',
size: '1024x1024',
partial_images: [
{
partial_image_index: 0,
partial_image_b64: 'partial',
},
],
},
]);
});
it('maps upstream chat-completion usage details into Responses usage details', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'chatcmpl_usage',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-5',
choices: [
{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
},
},
],
usage: {
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
prompt_tokens_details: {
cached_tokens: 5,
audio_tokens: 2,
},
completion_tokens_details: {
reasoning_tokens: 3,
audio_tokens: 1,
accepted_prediction_tokens: 4,
rejected_prediction_tokens: 6,
},
},
},
normalized: {
id: 'chatcmpl_usage',
model: 'gpt-5',
created: 1700000000,
content: 'hello',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 11,
completionTokens: 7,
totalTokens: 18,
},
});
expect(payload.usage).toMatchObject({
input_tokens: 11,
output_tokens: 7,
total_tokens: 18,
input_tokens_details: {
cached_tokens: 5,
audio_tokens: 2,
},
output_tokens_details: {
reasoning_tokens: 3,
audio_tokens: 1,
accepted_prediction_tokens: 4,
rejected_prediction_tokens: 6,
},
});
});
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: [],
},
]);
});
it('preserves image generation mime_type on synthesized response payloads', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'resp_like_mime_1',
model: 'gpt-5',
output: [
{
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
mime_type: 'image/png',
output_format: 'png',
},
],
},
normalized: {
id: 'resp_like_mime_1',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
mime_type: 'image/png',
output_format: 'png',
partial_images: [],
},
]);
});
});