1
0
forked from boos/BoosAPI

feat: enhance response serialization for compact mode

- Added support for preserving native response.compaction payloads when compact serialization is requested in `serializeResponsesFinalPayload`.
- Implemented serialization of compact mode as `response.compaction` instead of an ordinary response object.
- Introduced a new type `ResponsesFinalSerializationMode` to manage serialization modes.
- Refactored `serializeResponsesFinalPayload` to handle different serialization modes and usage payloads.
- Added tests to ensure correct behavior for compact serialization and preservation of upstream payloads.
- Updated favicon assets and documentation configuration for VitePress.
This commit is contained in:
cita
2026-03-10 01:28:34 +08:00
parent 74d2e559ec
commit cd031b732f
20 changed files with 1387 additions and 90 deletions
@@ -114,6 +114,7 @@ describe('proxy route architecture boundaries', () => {
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
expect(source).not.toContain('reply.raw.end();');
expect(source).toContain('reply.hijack();');
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
});
});
@@ -137,4 +137,49 @@ describe('responses proxy compact upstream routing', () => {
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/responses/compact');
});
it('preserves native response.compaction payloads instead of coercing them into object=response', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'cmp_123',
object: 'response.compaction',
input_tokens: 1234,
output_tokens: 321,
total_tokens: 1555,
output: [
{
id: 'rs_123',
type: 'compaction',
encrypted_content: 'enc-compact-payload',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
id: 'cmp_123',
object: 'response.compaction',
input_tokens: 1234,
output_tokens: 321,
total_tokens: 1555,
output: [
{
id: 'rs_123',
type: 'compaction',
encrypted_content: 'enc-compact-payload',
},
],
});
});
});
@@ -29,4 +29,24 @@ describe('responses proxy compact route', () => {
},
});
});
it('rejects streaming /v1/responses/compact requests because compact is non-streaming', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
stream: true,
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: {
message: 'stream is not supported on /v1/responses/compact',
type: 'invalid_request_error',
},
});
});
});
@@ -502,4 +502,265 @@ describe('serializeConvertedResponsesEvents', () => {
},
});
});
it('does not synthesize duplicate message done events after original text completion already arrived', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 2,
completionTokens: 4,
totalTokens: 6,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.output_item.added',
responsesPayload: {
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'in_progress',
content: [],
},
},
},
});
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.content_part.added',
responsesPayload: {
type: 'response.content_part.added',
output_index: 0,
item_id: 'msg_1',
content_index: 0,
part: {
type: 'output_text',
text: '',
},
},
},
});
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.output_text.delta',
responsesPayload: {
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_1',
delta: 'hello ',
},
},
});
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.output_text.done',
responsesPayload: {
type: 'response.output_text.done',
output_index: 0,
item_id: 'msg_1',
text: 'hello world',
},
},
});
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.content_part.done',
responsesPayload: {
type: 'response.content_part.done',
output_index: 0,
item_id: 'msg_1',
content_index: 0,
part: {
type: 'output_text',
text: 'hello world',
},
},
},
});
const completionLines = completeResponsesStream(state, streamContext, usage);
const events = parseSseEvents(completionLines);
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
'response.output_item.done',
'response.completed',
'[DONE]',
]);
expect(events[0]?.payload).toMatchObject({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'msg_1',
type: 'message',
status: 'completed',
},
});
expect(events[1]?.payload).toMatchObject({
type: 'response.completed',
response: {
status: 'completed',
output_text: 'hello world',
},
});
});
it('does not synthesize duplicate reasoning done events after original reasoning completion already arrived', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 2,
completionTokens: 4,
totalTokens: 6,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.output_item.added',
responsesPayload: {
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'rs_1',
type: 'reasoning',
status: 'in_progress',
summary: [],
},
},
},
});
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.reasoning_summary_part.added',
responsesPayload: {
type: 'response.reasoning_summary_part.added',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: '',
},
},
},
});
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.reasoning_summary_text.delta',
responsesPayload: {
type: 'response.reasoning_summary_text.delta',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
delta: 'plan ',
},
},
});
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.reasoning_summary_text.done',
responsesPayload: {
type: 'response.reasoning_summary_text.done',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
text: 'plan first',
},
},
});
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.reasoning_summary_part.done',
responsesPayload: {
type: 'response.reasoning_summary_part.done',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: 'plan first',
},
},
},
});
const completionLines = completeResponsesStream(state, streamContext, usage);
const events = parseSseEvents(completionLines);
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
'response.output_item.done',
'response.completed',
'[DONE]',
]);
expect(events[0]?.payload).toMatchObject({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'rs_1',
type: 'reasoning',
status: 'completed',
},
});
expect(events[1]?.payload).toMatchObject({
type: 'response.completed',
response: {
status: 'completed',
output: [
{
id: 'rs_1',
type: 'reasoning',
status: 'completed',
summary: [
{
type: 'summary_text',
text: 'plan first',
},
],
},
],
},
});
});
});
@@ -55,6 +55,12 @@ type ResponsesUsageSummary = {
};
type AggregateOutputItem = Record<string, unknown>;
type AggregateOutputMetaCarrier = AggregateOutputItem & { [key: symbol]: true | undefined };
const outputTextDoneMarker = Symbol('responses.output_text.done');
const contentPartDoneMarker = Symbol('responses.content_part.done');
const reasoningSummaryTextDoneMarker = Symbol('responses.reasoning_summary_text.done');
const reasoningSummaryPartDoneMarker = Symbol('responses.reasoning_summary_part.done');
export type OpenAiResponsesAggregateState = {
modelName: string;
@@ -90,6 +96,14 @@ export function createOpenAiResponsesAggregateState(modelName: string): OpenAiRe
};
}
function markTerminalMarker(item: AggregateOutputItem, marker: symbol): void {
(item as AggregateOutputMetaCarrier)[marker] = true;
}
function hasTerminalMarker(item: AggregateOutputItem, marker: symbol): boolean {
return (item as AggregateOutputMetaCarrier)[marker] === true;
}
function mergeUsageExtras(
state: OpenAiResponsesAggregateState,
usagePayload: unknown,
@@ -523,8 +537,20 @@ function applyOriginalResponsesPayload(
const message = ensureMessageItem(state, outputIndex).item;
const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : [];
if (!Array.isArray(message.content)) message.content = content;
const part = cloneRecord(payload.part);
if (part) content[contentIndex] = part;
const existingPart = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : null;
const incomingPart = cloneRecord(payload.part);
if (incomingPart) {
content[contentIndex] = existingPart
? {
...existingPart,
...incomingPart,
}
: incomingPart;
}
const part = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : existingPart;
if (eventType === 'response.content_part.done' && part) {
markTerminalMarker(part, contentPartDoneMarker);
}
return serializeOriginalResponsesEvent(eventType, payload);
}
case 'response.output_text.delta':
@@ -533,6 +559,7 @@ function applyOriginalResponsesPayload(
const textPart = ensureMessageOutputTextPart(state, outputIndex);
if (eventType === 'response.output_text.done') {
textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
markTerminalMarker(textPart.part, outputTextDoneMarker);
} else {
textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
}
@@ -580,6 +607,11 @@ function applyOriginalResponsesPayload(
...part,
};
summaryState.item.summary = summary;
if (eventType === 'response.reasoning_summary_part.done') {
markTerminalMarker(summary[summaryState.summaryIndex] as AggregateOutputItem, reasoningSummaryPartDoneMarker);
}
} else if (eventType === 'response.reasoning_summary_part.done') {
markTerminalMarker(summaryState.summary, reasoningSummaryPartDoneMarker);
}
return serializeOriginalResponsesEvent(eventType, payload);
}
@@ -588,6 +620,7 @@ function applyOriginalResponsesPayload(
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
if (eventType === 'response.reasoning_summary_text.done') {
summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
markTerminalMarker(summaryState.summary, reasoningSummaryTextDoneMarker);
} else {
summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
}
@@ -790,39 +823,51 @@ function buildSyntheticTerminalItemDoneEvents(
const part = content[0];
if (isRecord(part)) {
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
lines.push(serializeSse('response.output_text.done', {
type: 'response.output_text.done',
output_index: index,
item_id: itemId,
text,
}));
lines.push(serializeSse('response.content_part.done', {
type: 'response.content_part.done',
output_index: index,
item_id: itemId,
content_index: 0,
part: cloneJson(part),
}));
if (!hasTerminalMarker(part, outputTextDoneMarker)) {
lines.push(serializeSse('response.output_text.done', {
type: 'response.output_text.done',
output_index: index,
item_id: itemId,
text,
}));
markTerminalMarker(part, outputTextDoneMarker);
}
if (!hasTerminalMarker(part, contentPartDoneMarker)) {
lines.push(serializeSse('response.content_part.done', {
type: 'response.content_part.done',
output_index: index,
item_id: itemId,
content_index: 0,
part: cloneJson(part),
}));
markTerminalMarker(part, contentPartDoneMarker);
}
}
} else if (itemType === 'reasoning') {
const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : [];
const part = summary[0];
if (isRecord(part)) {
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
lines.push(serializeSse('response.reasoning_summary_text.done', {
type: 'response.reasoning_summary_text.done',
item_id: itemId,
output_index: index,
summary_index: 0,
text,
}));
lines.push(serializeSse('response.reasoning_summary_part.done', {
type: 'response.reasoning_summary_part.done',
item_id: itemId,
output_index: index,
summary_index: 0,
part: cloneJson(part),
}));
if (!hasTerminalMarker(part, reasoningSummaryTextDoneMarker)) {
lines.push(serializeSse('response.reasoning_summary_text.done', {
type: 'response.reasoning_summary_text.done',
item_id: itemId,
output_index: index,
summary_index: 0,
text,
}));
markTerminalMarker(part, reasoningSummaryTextDoneMarker);
}
if (!hasTerminalMarker(part, reasoningSummaryPartDoneMarker)) {
lines.push(serializeSse('response.reasoning_summary_part.done', {
type: 'response.reasoning_summary_part.done',
item_id: itemId,
output_index: index,
summary_index: 0,
part: cloneJson(part),
}));
markTerminalMarker(part, reasoningSummaryPartDoneMarker);
}
}
} else if (itemType === 'function_call') {
const callId = asTrimmedString(item.call_id) || itemId;
@@ -3,6 +3,112 @@ 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('preserves top-level chat annotations on synthetic assistant messages', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
@@ -64,6 +64,8 @@ export type ResponsesToolCall = {
arguments: string;
};
export type ResponsesFinalSerializationMode = 'response' | 'compact';
type ResponsesOutputItem = Record<string, unknown>;
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
@@ -280,14 +282,45 @@ export function normalizeResponsesFinalPayload(
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
}
function buildResponsesUsagePayload(
usage: ResponsesUsageSummary,
syntheticUsageDetails: {
inputTokensDetails?: Record<string, unknown>;
outputTokensDetails?: Record<string, unknown>;
},
): Record<string, unknown> {
return {
input_tokens: usage.promptTokens,
output_tokens: usage.completionTokens,
total_tokens: usage.totalTokens,
...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails
? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) }
: {}),
...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails
? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) }
: {}),
};
}
export function serializeResponsesFinalPayload(input: {
upstreamPayload: unknown;
normalized: NormalizedFinalResponse;
usage: ResponsesUsageSummary;
serializationMode?: ResponsesFinalSerializationMode;
}): Record<string, unknown> {
const { upstreamPayload, normalized, usage } = input;
if (isRecord(upstreamPayload) && upstreamPayload.object === 'response') {
return upstreamPayload;
const {
upstreamPayload,
normalized,
usage,
serializationMode = 'response',
} = input;
if (isRecord(upstreamPayload)) {
if (upstreamPayload.object === 'response.compaction') {
return upstreamPayload;
}
if (serializationMode === 'response' && upstreamPayload.object === 'response') {
return upstreamPayload;
}
}
const normalizedId = typeof normalized.id === 'string' && normalized.id.trim()
@@ -371,6 +404,17 @@ export function serializeResponsesFinalPayload(input: {
}
}
const usagePayload = buildResponsesUsagePayload(usage, syntheticUsageDetails);
if (serializationMode === 'compact') {
return {
id: responseId,
object: 'response.compaction',
created_at: normalized.created,
output,
usage: usagePayload,
};
}
return {
id: responseId,
object: 'response',
@@ -379,17 +423,7 @@ export function serializeResponsesFinalPayload(input: {
model: normalized.model,
output,
output_text: normalized.content || collectOutputTextFromItems(output),
usage: {
input_tokens: usage.promptTokens,
output_tokens: usage.completionTokens,
total_tokens: usage.totalTokens,
...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails
? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) }
: {}),
...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails
? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) }
: {}),
},
usage: usagePayload,
};
}