feat: Implement compatibility strategies for responses and chat endpoints

- Added `routeCompatibility.ts` to handle response compatibility for different endpoints.
- Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic.
- Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`.
- Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints.
- Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`.
- Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests.
- Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
cita
2026-03-09 22:48:10 +08:00
parent 15d4b207cc
commit 0f8176cd04
74 changed files with 4645 additions and 1565 deletions
+21 -10
View File
@@ -8,6 +8,7 @@ import type {
OpenAiChatUsageDetails,
} from './model.js';
import { fromTransformerMetadataRecord } from '../../shared/normalized.js';
import { extractInlineThinkTags } from '../../shared/thinkTagParser.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
@@ -98,15 +99,23 @@ function extractTextPart(value: unknown): string {
return '';
}
function joinNonEmpty(parts: string[]): string {
return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n');
}
function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } {
if (typeof value === 'string') return { content: value, reasoning: '' };
if (typeof value === 'string') return extractInlineThinkTags(value);
if (Array.isArray(value)) {
const contentParts: string[] = [];
const reasoningParts: string[] = [];
for (const item of value) {
if (!isRecord(item)) {
if (typeof item === 'string') contentParts.push(item);
if (typeof item === 'string') {
const parsed = extractInlineThinkTags(item);
if (parsed.content) contentParts.push(parsed.content);
if (parsed.reasoning) reasoningParts.push(parsed.reasoning);
}
continue;
}
@@ -119,8 +128,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
reasoningParts.push(item.reasoning);
continue;
}
const text = extractTextPart(item);
if (text) contentParts.push(text);
const parsed = extractInlineThinkTags(extractTextPart(item));
if (parsed.content) contentParts.push(parsed.content);
if (parsed.reasoning) reasoningParts.push(parsed.reasoning);
}
return {
@@ -130,13 +140,14 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
}
if (!isRecord(value)) return { content: '', reasoning: '' };
const parsed = extractInlineThinkTags(extractTextPart(value.content ?? value));
return {
content: extractTextPart(value.content ?? value),
reasoning: typeof value.reasoning_content === 'string'
? value.reasoning_content
: typeof value.reasoning === 'string'
? value.reasoning
: '',
content: parsed.content,
reasoning: joinNonEmpty([
typeof value.reasoning_content === 'string' ? value.reasoning_content : '',
typeof value.reasoning === 'string' ? value.reasoning : '',
parsed.reasoning,
]),
};
}
+29 -7
View File
@@ -1,19 +1,41 @@
import { parseDownstreamChatRequest } from '../../shared/normalized.js';
import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js';
import { extractChatRequestMetadata } from './helpers.js';
import type { OpenAiChatParsedRequest } from './model.js';
import type { OpenAiChatParsedRequest, OpenAiChatRequestEnvelope } from './model.js';
export const openAiChatInbound = {
parse(body: unknown): { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown } } {
parse(body: unknown): { value?: OpenAiChatRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
const parsed = parseDownstreamChatRequest(body, 'openai') as {
value?: OpenAiChatParsedRequest;
error?: { statusCode: number; payload: unknown };
};
if (!parsed.value) return parsed;
if (parsed.error) {
return { error: parsed.error };
}
if (!parsed.value) {
return {
error: {
statusCode: 400,
payload: {
error: {
message: 'invalid chat request',
type: 'invalid_request_error',
},
},
},
};
}
parsed.value = {
...parsed.value,
requestMetadata: extractChatRequestMetadata(body),
const metadata = extractChatRequestMetadata(body);
return {
value: createProtocolRequestEnvelope({
protocol: 'openai/chat',
model: parsed.value.requestedModel,
stream: parsed.value.isStream,
rawBody: body,
parsed: parsed.value,
...(metadata ? { metadata } : {}),
}),
};
return parsed;
},
};
@@ -31,7 +31,16 @@ describe('openAiChatTransformer.inbound', () => {
});
expect(result.error).toBeUndefined();
expect(result.value?.upstreamBody).toMatchObject({
expect(result.value).toMatchObject({
protocol: 'openai/chat',
model: 'gpt-5',
stream: false,
rawBody: {
model: 'gpt-5',
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(result.value?.parsed.upstreamBody).toMatchObject({
modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'mp3' },
reasoning_effort: 'high',
@@ -46,7 +55,7 @@ describe('openAiChatTransformer.inbound', () => {
response_format: { type: 'json_object' },
stream_options: { include_usage: true },
});
expect((result.value as any)?.requestMetadata).toEqual({
expect((result.value as any)?.metadata).toEqual({
modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'mp3' },
reasoningEffort: 'high',
@@ -82,14 +91,19 @@ describe('openAiChatTransformer.inbound', () => {
});
expect(result.error).toBeUndefined();
expect(result.value?.upstreamBody).toMatchObject({
expect(result.value).toMatchObject({
protocol: 'openai/chat',
model: 'gpt-5',
stream: false,
});
expect(result.value?.parsed.upstreamBody).toMatchObject({
modalities: ['text', 42, 'audio', '', null],
reasoning_budget: '2048',
top_logprobs: '5',
logit_bias: { '42': '7', invalid: 'oops' },
stream_options: { include_usage: 1 },
});
expect((result.value as any)?.requestMetadata).toEqual({
expect((result.value as any)?.metadata).toEqual({
modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'wav' },
reasoningEffort: 'medium',
@@ -107,6 +121,28 @@ describe('openAiChatTransformer.inbound', () => {
});
describe('openAiChatTransformer.outbound', () => {
it('normalizes inline think tags in final chat responses', () => {
const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-inline-think',
model: 'gpt-5',
created: 123,
choices: [{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: '<think>plan quietly</think>visible answer',
},
}],
}, 'gpt-5');
expect(normalized).toMatchObject({
content: 'visible answer',
reasoningContent: 'plan quietly',
finishReason: 'stop',
});
});
it('carries annotations, citations, and detailed usage through final serialization', () => {
const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-1',
@@ -402,6 +438,53 @@ describe('openAiChatTransformer.stream', () => {
'https://b.example',
]);
});
it('normalizes inline think tags inside multi-choice stream chunks', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5');
const event = openAiChatTransformer.transformStreamEvent({
id: 'chatcmpl-stream-multi-think',
model: 'gpt-5',
choices: [
{
index: 0,
finish_reason: null,
delta: {
role: 'assistant',
content: '<think>plan-0</think>choice-0',
},
},
{
index: 1,
finish_reason: null,
delta: {
role: 'assistant',
content: '<think>plan-1</think>choice-1',
},
},
],
}, context, 'gpt-5');
const payloads = parseSsePayloads(
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
);
expect((payloads[0] as any).choices[0]).toMatchObject({
index: 0,
delta: {
role: 'assistant',
content: 'choice-0',
reasoning_content: 'plan-0',
},
});
expect((payloads[0] as any).choices[1]).toMatchObject({
index: 1,
delta: {
role: 'assistant',
content: 'choice-1',
reasoning_content: 'plan-1',
},
});
});
});
describe('openAiChatTransformer.aggregator', () => {
+13 -1
View File
@@ -1,10 +1,15 @@
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
import { openAiChatInbound } from './inbound.js';
import { openAiChatOutbound } from './outbound.js';
import { createChatProxyStreamSession } from './proxyStream.js';
import { openAiChatStream } from './stream.js';
import { openAiChatUsage } from './usage.js';
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
import type { OpenAiChatParsedRequest as OpenAiChatParsedRequestModel } from './model.js';
import type {
OpenAiChatParsedRequest as OpenAiChatParsedRequestModel,
OpenAiChatRequestEnvelope as OpenAiChatRequestEnvelopeModel,
} from './model.js';
export const openAiChatTransformer = {
protocol: 'openai/chat' as const,
@@ -12,11 +17,17 @@ export const openAiChatTransformer = {
outbound: openAiChatOutbound,
stream: openAiChatStream,
usage: openAiChatUsage,
compatibility: {
createEndpointStrategy: createChatEndpointStrategy,
},
aggregator: {
createState: createOpenAiChatAggregateState,
applyEvent: applyOpenAiChatStreamEvent,
finalize: finalizeOpenAiChatAggregate,
},
proxyStream: {
createSession: createChatProxyStreamSession,
},
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
return openAiChatInbound.parse(body);
},
@@ -72,3 +83,4 @@ export const openAiChatTransformer = {
export type OpenAiChatTransformer = typeof openAiChatTransformer;
export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel;
export type OpenAiChatRequestEnvelope = OpenAiChatRequestEnvelopeModel;
+7 -3
View File
@@ -3,6 +3,7 @@ import type {
NormalizedStreamEvent,
ParsedDownstreamChatRequest,
} from '../../shared/normalized.js';
import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
export type OpenAiChatAudioRequest = {
format?: string;
@@ -31,9 +32,12 @@ export type OpenAiChatUsageDetails = {
completion_tokens_details?: Record<string, number>;
};
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & {
requestMetadata?: OpenAiChatRequestMetadata;
};
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
export type OpenAiChatRequestEnvelope = ProtocolRequestEnvelope<
'openai/chat',
OpenAiChatParsedRequest,
OpenAiChatRequestMetadata
>;
export type OpenAiChatToolCall = {
id: string;
@@ -0,0 +1,171 @@
import { anthropicMessagesTransformer } from '../../anthropic/messages/index.js';
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
import { type DownstreamFormat, type ParsedSseEvent } from '../../shared/normalized.js';
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
import { openAiChatOutbound } from './outbound.js';
import { openAiChatStream } from './stream.js';
type StreamReader = {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
};
type ChatProxyStreamSessionInput = {
downstreamFormat: DownstreamFormat;
modelName: string;
onParsedPayload?: (payload: unknown) => void;
writeLines: (lines: string[]) => void;
writeRaw: (chunk: string) => void;
};
type ResponseSink = {
end(): void;
};
export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) {
const downstreamTransformer = input.downstreamFormat === 'claude'
? anthropicMessagesTransformer
: {
createStreamContext: openAiChatStream.createContext,
transformStreamEvent: openAiChatStream.normalizeEvent,
serializeStreamEvent: openAiChatStream.serializeEvent,
serializeDone: openAiChatStream.serializeDone,
pullSseEvents: openAiChatStream.pullSseEvents,
};
const streamContext = downstreamTransformer.createStreamContext(input.modelName);
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
const chatAggregateState = input.downstreamFormat === 'openai'
? createOpenAiChatAggregateState()
: null;
let finalized = false;
const finalize = () => {
if (finalized) return;
finalized = true;
// For native Anthropic streams, EOF without message_stop is not a clean
// completion. Forward the partial stream as-is instead of fabricating an
// end_turn/message_stop pair that makes clients think the run finished.
if (input.downstreamFormat === 'claude' && !claudeContext.doneSent) {
return;
}
if (input.downstreamFormat === 'openai' && chatAggregateState && chatAggregateState.choices.size > 0) {
const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values())
.some((choice) => !choice.finishReason);
if (needsTerminalFinishChunk) {
const terminalChunk = openAiChatOutbound.buildSyntheticChunks(
finalizeOpenAiChatAggregate(chatAggregateState, {
id: streamContext.id,
model: streamContext.model,
created: streamContext.created,
content: '',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
}),
).slice(-1)[0];
if (terminalChunk) {
input.writeLines([`data: ${JSON.stringify(terminalChunk)}\n\n`]);
}
}
}
input.writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
};
const handleEventBlock = async (eventBlock: ParsedSseEvent): Promise<boolean> => {
if (eventBlock.data === '[DONE]') {
finalize();
return true;
}
let parsedPayload: unknown = null;
if (input.downstreamFormat === 'claude') {
const consumed = anthropicMessagesTransformer.consumeSseEventBlock(
eventBlock,
streamContext,
claudeContext,
input.modelName,
);
parsedPayload = consumed.parsedPayload;
if (parsedPayload && typeof parsedPayload === 'object') {
input.onParsedPayload?.(parsedPayload);
}
if (consumed.handled) {
input.writeLines(consumed.lines);
return consumed.done;
}
} else {
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
input.onParsedPayload?.(parsedPayload);
}
}
if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName);
if (input.downstreamFormat === 'openai' && chatAggregateState) {
applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent);
}
input.writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
return input.downstreamFormat === 'claude' && claudeContext.doneSent;
}
if (input.downstreamFormat === 'openai') {
input.writeRaw(`data: ${eventBlock.data}\n\n`);
return false;
}
input.writeLines(anthropicMessagesTransformer.serializeStreamEvent({
contentDelta: eventBlock.data,
}, streamContext, claudeContext));
return claudeContext.doneSent;
};
return {
consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink) {
if (payload && typeof payload === 'object') {
input.onParsedPayload?.(payload);
}
if (input.downstreamFormat === 'openai') {
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText);
streamContext.id = normalizedFinal.id;
streamContext.model = normalizedFinal.model;
streamContext.created = normalizedFinal.created;
input.writeLines(
openAiChatOutbound
.buildSyntheticChunks(normalizedFinal)
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`),
);
} else {
input.writeLines(
anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
payload,
input.modelName,
fallbackText,
streamContext,
claudeContext,
),
);
}
finalize();
response?.end();
},
async run(reader: StreamReader | null | undefined, response: ResponseSink) {
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
reader,
response,
pullEvents: (buffer) => downstreamTransformer.pullSseEvents(buffer),
handleEvent: handleEventBlock,
onEof: finalize,
});
await lifecycle.run();
},
};
}
+19 -7
View File
@@ -36,23 +36,35 @@ export const openAiChatStream = {
return createStreamTransformContext(modelName);
},
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName);
const choiceEvents = extractChatChoiceEvents(payload);
const primaryChoice = choiceEvents[0];
const normalizedChoiceEvents = choiceEvents.map((choiceEvent, index) => (
index === 0
? {
...choiceEvent,
role: normalized.role !== undefined ? normalized.role : choiceEvent.role,
contentDelta: normalized.contentDelta,
reasoningDelta: normalized.reasoningDelta,
toolCallDeltas: normalized.toolCallDeltas !== undefined
? normalized.toolCallDeltas
: choiceEvent.toolCallDeltas,
finishReason: normalized.finishReason !== undefined
? normalized.finishReason
: choiceEvent.finishReason,
}
: choiceEvent
));
return {
...normalizeUpstreamStreamEvent(payload, context, modelName),
...normalized,
...(primaryChoice
? {
choiceIndex: primaryChoice.index,
role: primaryChoice.role,
contentDelta: primaryChoice.contentDelta,
reasoningDelta: primaryChoice.reasoningDelta,
toolCallDeltas: primaryChoice.toolCallDeltas,
finishReason: primaryChoice.finishReason,
annotations: primaryChoice.annotations,
citations: primaryChoice.citations,
}
: {}),
...(choiceEvents.length > 0 ? { choiceEvents } : {}),
...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}),
...extractChatResponseExtras(payload),
};
},
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
import { createStreamTransformContext } from '../../shared/normalized.js';
import {
completeResponsesStream,
createOpenAiResponsesAggregateState,
failResponsesStream,
serializeConvertedResponsesEvents,
} from './aggregator.js';
@@ -23,6 +25,35 @@ function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
.filter((item): item is Record<string, unknown> => !!item);
}
function parseSseEvents(lines: string[]): Array<{ event: string | null; payload: Record<string, unknown> | '[DONE]' }> {
return lines
.flatMap((line) => line.split('\n\n').filter((block) => block.trim().length > 0))
.map((block) => {
const eventLine = block
.split('\n')
.find((line) => line.startsWith('event: '));
const dataLine = block
.split('\n')
.find((line) => line.startsWith('data: '));
if (!dataLine) return null;
if (dataLine === 'data: [DONE]') {
return {
event: eventLine ? eventLine.slice('event: '.length) : null,
payload: '[DONE]' as const,
};
}
try {
return {
event: eventLine ? eventLine.slice('event: '.length) : null,
payload: JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>,
};
} catch {
return null;
}
})
.filter((item): item is { event: string | null; payload: Record<string, unknown> | '[DONE]' } => !!item);
}
describe('serializeConvertedResponsesEvents', () => {
it('aggregates reasoning summary events into the completed response payload', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
@@ -301,4 +332,174 @@ describe('serializeConvertedResponsesEvents', () => {
],
});
});
it('emits canonical message done events before response.completed when recovering a sparse text stream', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 2,
completionTokens: 4,
totalTokens: 6,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
contentDelta: 'hello world',
} as any,
});
const completionLines = completeResponsesStream(state, streamContext, usage);
const events = parseSseEvents(completionLines);
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
'response.output_text.done',
'response.content_part.done',
'response.output_item.done',
'response.completed',
'[DONE]',
]);
expect(events[0]?.payload).toMatchObject({
type: 'response.output_text.done',
output_index: 0,
item_id: 'msg_0',
text: 'hello world',
});
expect(events[1]?.payload).toMatchObject({
type: 'response.content_part.done',
output_index: 0,
item_id: 'msg_0',
content_index: 0,
part: {
type: 'output_text',
text: 'hello world',
},
});
expect(events[2]?.payload).toMatchObject({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'msg_0',
type: 'message',
status: 'completed',
},
});
});
it('emits canonical reasoning done events before response.completed when recovering sparse reasoning output', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 2,
completionTokens: 4,
totalTokens: 6,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
reasoningSignature: 'enc-signature',
reasoningDelta: 'plan first',
} as any,
});
const completionLines = completeResponsesStream(state, streamContext, usage);
const events = parseSseEvents(completionLines);
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
'response.reasoning_summary_text.done',
'response.reasoning_summary_part.done',
'response.output_item.done',
'response.completed',
'[DONE]',
]);
expect(events[0]?.payload).toMatchObject({
type: 'response.reasoning_summary_text.done',
item_id: 'rs_0',
output_index: 0,
summary_index: 0,
text: 'plan first',
});
expect(events[1]?.payload).toMatchObject({
type: 'response.reasoning_summary_part.done',
item_id: 'rs_0',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: 'plan first',
},
});
expect(events[2]?.payload).toMatchObject({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'rs_0',
type: 'reasoning',
status: 'completed',
encrypted_content: 'enc-signature',
},
});
});
it('emits canonical message done events before response.failed when failing a sparse text stream', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 2,
completionTokens: 4,
totalTokens: 6,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
contentDelta: 'partial answer',
} as any,
});
const failedLines = failResponsesStream(state, streamContext, usage, {
error: {
message: 'upstream stream failed',
},
});
const events = parseSseEvents(failedLines);
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
'response.output_text.done',
'response.content_part.done',
'response.output_item.done',
'response.failed',
'[DONE]',
]);
expect(events[2]?.payload).toMatchObject({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'msg_0',
type: 'message',
status: 'failed',
},
});
expect(events[3]?.payload).toMatchObject({
type: 'response.failed',
response: {
status: 'failed',
output_text: 'partial answer',
},
error: {
message: 'upstream stream failed',
type: 'upstream_error',
},
});
});
});
@@ -768,6 +768,99 @@ function buildSyntheticToolEvents(
return lines;
}
function buildSyntheticTerminalItemDoneEvents(
state: OpenAiResponsesAggregateState,
status: 'completed' | 'failed',
): string[] {
const lines: string[] = [];
for (let index = 0; index < state.outputItems.length; index += 1) {
const item = state.outputItems[index];
if (!isRecord(item)) continue;
const currentStatus = asTrimmedString(item.status).toLowerCase();
if (currentStatus === 'completed' || currentStatus === 'failed') continue;
const itemType = asTrimmedString(item.type).toLowerCase();
const itemId = ensureOutputItemId(asTrimmedString(item.id), 'out', index);
item.id = itemId;
if (itemType === 'message') {
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
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),
}));
}
} 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),
}));
}
} else if (itemType === 'function_call') {
const callId = asTrimmedString(item.call_id) || itemId;
const name = asTrimmedString(item.name);
const argumentsText = typeof item.arguments === 'string' ? item.arguments : String(item.arguments ?? '');
lines.push(serializeSse('response.function_call_arguments.done', {
type: 'response.function_call_arguments.done',
item_id: itemId,
call_id: callId,
output_index: index,
...(name ? { name } : {}),
arguments: argumentsText,
}));
} else if (itemType === 'custom_tool_call') {
const callId = asTrimmedString(item.call_id) || itemId;
const name = asTrimmedString(item.name);
const inputText = typeof item.input === 'string' ? item.input : String(item.input ?? '');
lines.push(serializeSse('response.custom_tool_call_input.done', {
type: 'response.custom_tool_call_input.done',
item_id: itemId,
call_id: callId,
output_index: index,
...(name ? { name } : {}),
input: inputText,
}));
}
item.status = status;
lines.push(serializeSse('response.output_item.done', {
type: 'response.output_item.done',
output_index: index,
item: cloneJson(item),
}));
}
return lines;
}
export function serializeConvertedResponsesEvents(input: {
state: OpenAiResponsesAggregateState;
streamContext: StreamTransformContext;
@@ -806,8 +899,10 @@ export function completeResponsesStream(
if (state.failed || state.completed) {
return [serializeDone()];
}
const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
state.completed = true;
return [
...lines,
serializeSse('response.completed', {
type: 'response.completed',
response: materializeResponse(state, streamContext, usage, null, 'completed'),
@@ -825,6 +920,7 @@ export function failResponsesStream(
if (state.failed) {
return [serializeDone()];
}
const lines = buildSyntheticTerminalItemDoneEvents(state, 'failed');
state.failed = true;
const errorPayload = cloneRecord(payload);
const message = (
@@ -833,6 +929,7 @@ export function failResponsesStream(
: (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed')
);
return [
...lines,
serializeSse('response.failed', {
type: 'response.failed',
response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'),
@@ -1,6 +1,55 @@
import { normalizeResponsesInputForCompatibility, normalizeResponsesMessageItem } from './compatibility.js';
import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js';
import { sanitizeResponsesBodyForProxy } from './conversion.js';
import type {
OpenAiResponsesParsedRequest,
OpenAiResponsesRequestEnvelope,
} from './model.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function invalidRequest(message: string): { statusCode: number; payload: unknown } {
return {
statusCode: 400,
payload: {
error: {
message,
type: 'invalid_request_error',
},
},
};
}
export const openAiResponsesInbound = {
normalizeInput: normalizeResponsesInputForCompatibility,
normalizeMessage: normalizeResponsesMessageItem,
parse(
body: unknown,
options?: { defaultEncryptedReasoningInclude?: boolean },
): { value?: OpenAiResponsesRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
const rawBody = isRecord(body) ? body : {};
const requestedModel = typeof rawBody.model === 'string' ? rawBody.model.trim() : '';
if (!requestedModel) {
return { error: invalidRequest('model is required') };
}
const isStream = rawBody.stream === true;
const normalizedBody = sanitizeResponsesBodyForProxy(
rawBody,
requestedModel,
isStream,
{ defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude },
);
return {
value: createProtocolRequestEnvelope({
protocol: 'openai/responses',
model: requestedModel,
stream: isStream,
rawBody: body,
parsed: {
normalizedBody,
} satisfies OpenAiResponsesParsedRequest,
}),
};
},
};
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { openAiResponsesTransformer } from './index.js';
describe('openAiResponsesTransformer.inbound', () => {
it('returns a protocol request envelope with a normalized responses body', () => {
const result = openAiResponsesTransformer.transformRequest({
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
});
expect(result.error).toBeUndefined();
expect(result.value).toMatchObject({
protocol: 'openai/responses',
model: 'gpt-5',
stream: false,
rawBody: {
model: 'gpt-5',
input: 'hello',
},
parsed: {
normalizedBody: {
model: 'gpt-5',
input: [
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: 'hello',
},
],
},
],
stream: false,
},
},
});
});
it('rejects requests without a model at the transformer boundary', () => {
const result = openAiResponsesTransformer.transformRequest({
input: 'hello',
});
expect(result.error).toEqual({
statusCode: 400,
payload: {
error: {
message: 'model is required',
type: 'invalid_request_error',
},
},
});
});
});
@@ -21,12 +21,20 @@ import {
serializeConvertedResponsesEvents,
} from './aggregator.js';
import { openAiResponsesOutbound } from './outbound.js';
import { openAiResponsesInbound } from './inbound.js';
import { createResponsesProxyStreamSession } from './proxyStream.js';
import { createResponsesEndpointStrategy } from './routeCompatibility.js';
import { openAiResponsesStream } from './stream.js';
import { openAiResponsesUsage } from './usage.js';
import type {
OpenAiResponsesParsedRequest as OpenAiResponsesParsedRequestModel,
OpenAiResponsesRequestEnvelope as OpenAiResponsesRequestEnvelopeModel,
} from './model.js';
export const openAiResponsesTransformer = {
protocol: 'openai/responses' as const,
inbound: {
parse: openAiResponsesInbound.parse,
normalizeInput: normalizeResponsesInputForCompatibility,
normalizeMessage: normalizeResponsesMessageItem,
normalizeContent: normalizeResponsesMessageContent,
@@ -38,6 +46,7 @@ export const openAiResponsesTransformer = {
stream: openAiResponsesStream,
usage: openAiResponsesUsage,
compatibility: {
createEndpointStrategy: createResponsesEndpointStrategy,
buildRetryBodies,
buildRetryHeaders,
shouldRetry,
@@ -49,8 +58,14 @@ export const openAiResponsesTransformer = {
complete: completeResponsesStream,
fail: failResponsesStream,
},
transformRequest(body: unknown) {
return body;
proxyStream: {
createSession: createResponsesProxyStreamSession,
},
transformRequest(
body: unknown,
options?: { defaultEncryptedReasoningInclude?: boolean },
): { value?: OpenAiResponsesRequestEnvelopeModel; error?: { statusCode: number; payload: unknown } } {
return openAiResponsesInbound.parse(body, options);
},
createStreamContext(modelName: string): StreamTransformContext {
return openAiResponsesStream.createContext(modelName);
@@ -68,6 +83,8 @@ export const openAiResponsesTransformer = {
export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer;
export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState;
export type OpenAiResponsesParsedRequest = OpenAiResponsesParsedRequestModel;
export type OpenAiResponsesRequestEnvelope = OpenAiResponsesRequestEnvelopeModel;
export {
convertOpenAiBodyToResponsesBody,
convertResponsesBodyToOpenAiBody,
@@ -0,0 +1,10 @@
import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
export type OpenAiResponsesParsedRequest = {
normalizedBody: Record<string, unknown>;
};
export type OpenAiResponsesRequestEnvelope = ProtocolRequestEnvelope<
'openai/responses',
OpenAiResponsesParsedRequest
>;
@@ -0,0 +1,111 @@
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
import { type ParsedSseEvent } from '../../shared/normalized.js';
import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js';
import { openAiResponsesStream } from './stream.js';
type StreamReader = {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
};
type ResponseSink = {
end(): void;
};
type ResponsesProxyStreamSessionInput = {
modelName: string;
successfulUpstreamPath: string;
getUsage: () => {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
promptTokensIncludeCache: boolean | null;
};
onParsedPayload?: (payload: unknown) => void;
writeLines: (lines: string[]) => void;
writeRaw: (chunk: string) => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) {
const streamContext = openAiResponsesStream.createContext(input.modelName);
const responsesState = createOpenAiResponsesAggregateState(input.modelName);
let finalized = false;
const finalize = () => {
if (finalized) return;
finalized = true;
input.writeLines(completeResponsesStream(responsesState, streamContext, input.getUsage()));
};
const handleEventBlock = (eventBlock: ParsedSseEvent): boolean => {
if (eventBlock.data === '[DONE]') {
finalize();
return true;
}
let parsedPayload: unknown = null;
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
input.onParsedPayload?.(parsedPayload);
}
const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string')
? parsedPayload.type
: '';
const isFailureEvent = (
eventBlock.event === 'error'
|| eventBlock.event === 'response.failed'
|| payloadType === 'error'
|| payloadType === 'response.failed'
);
if (isFailureEvent) {
input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), parsedPayload));
finalized = true;
return true;
}
if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName);
input.writeLines(serializeConvertedResponsesEvents({
state: responsesState,
streamContext,
event: normalizedEvent,
usage: input.getUsage(),
}));
return false;
}
input.writeLines(serializeConvertedResponsesEvents({
state: responsesState,
streamContext,
event: { contentDelta: eventBlock.data },
usage: input.getUsage(),
}));
return false;
};
return {
async run(reader: StreamReader | null | undefined, response: ResponseSink) {
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
reader,
response,
pullEvents: (buffer) => openAiResponsesStream.pullSseEvents(buffer),
handleEvent: handleEventBlock,
onEof: finalize,
});
await lifecycle.run();
},
};
}
@@ -0,0 +1,110 @@
import {
buildMinimalJsonHeadersForCompatibility,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
type CompatibilityEndpoint,
} from '../../shared/endpointCompatibility.js';
import type { EndpointAttemptContext, EndpointRecoverResult } from '../../../routes/proxy/endpointFlow.js';
import {
buildResponsesCompatibilityBodies,
buildResponsesCompatibilityHeaderCandidates,
shouldDowngradeResponsesChatToMessages,
shouldRetryResponsesCompatibility,
} from './compatibility.js';
type CompatibilityRequest = {
endpoint: CompatibilityEndpoint;
path: string;
headers: Record<string, string>;
body: Record<string, unknown>;
};
type UpstreamResponse = Exclude<EndpointRecoverResult, null>['upstream'];
type CreateResponsesEndpointStrategyInput = {
isStream: boolean;
requiresNativeResponsesFileUrl: boolean;
dispatchRequest: (
request: CompatibilityRequest,
targetUrl?: string,
) => Promise<UpstreamResponse>;
};
export function createResponsesEndpointStrategy(input: CreateResponsesEndpointStrategyInput) {
return {
async tryRecover(ctx: EndpointAttemptContext): Promise<EndpointRecoverResult> {
if (shouldRetryResponsesCompatibility({
endpoint: ctx.request.endpoint,
status: ctx.response.status,
rawErrText: ctx.rawErrText,
})) {
const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body);
const compatibilityHeaders = buildResponsesCompatibilityHeaderCandidates(
ctx.request.headers,
input.isStream,
);
for (const compatibilityHeadersCandidate of compatibilityHeaders) {
for (const compatibilityBody of compatibilityBodies) {
const compatibilityRequest = {
...ctx.request,
headers: compatibilityHeadersCandidate,
body: compatibilityBody,
};
const compatibilityResponse = await input.dispatchRequest(
compatibilityRequest,
ctx.targetUrl,
);
if (compatibilityResponse.ok) {
return {
upstream: compatibilityResponse,
upstreamPath: compatibilityRequest.path,
};
}
ctx.request = compatibilityRequest;
ctx.response = compatibilityResponse;
ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error');
}
}
}
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
return null;
}
const minimalRequest = {
...ctx.request,
headers: buildMinimalJsonHeadersForCompatibility({
headers: ctx.request.headers,
endpoint: ctx.request.endpoint,
stream: input.isStream,
}),
};
const minimalResponse = await input.dispatchRequest(minimalRequest, ctx.targetUrl);
if (minimalResponse.ok) {
return {
upstream: minimalResponse,
upstreamPath: minimalRequest.path,
};
}
ctx.request = minimalRequest;
ctx.response = minimalResponse;
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
return null;
},
shouldDowngrade(ctx: EndpointAttemptContext): boolean {
if (input.requiresNativeResponsesFileUrl) return false;
return (
ctx.response.status >= 500
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|| shouldDowngradeResponsesChatToMessages(
ctx.request.path,
ctx.response.status,
ctx.rawErrText,
)
);
},
};
}