forked from boos/BoosAPI
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:
@@ -54,7 +54,12 @@ describe('anthropicMessagesInbound', () => {
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toMatchObject({
|
||||
expect(result.value).toMatchObject({
|
||||
protocol: 'anthropic/messages',
|
||||
model: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
});
|
||||
expect(result.value?.parsed.claudeOriginalBody).toMatchObject({
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'high', preserve: true },
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
@@ -89,7 +94,7 @@ describe('anthropicMessagesInbound', () => {
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual({
|
||||
expect(result.value?.parsed.claudeOriginalBody).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
system: [
|
||||
@@ -151,6 +156,6 @@ describe('anthropicMessagesInbound', () => {
|
||||
const result = anthropicMessagesInbound.parse(nativeBody);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual(nativeBody);
|
||||
expect(result.value?.parsed.claudeOriginalBody).toEqual(nativeBody);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js';
|
||||
import { createProtocolRequestEnvelope, type ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
|
||||
import { validateAnthropicMessagesBody } from './conversion.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -136,7 +137,10 @@ function sanitizeAnthropicInboundBody(
|
||||
}
|
||||
|
||||
export const anthropicMessagesInbound = {
|
||||
parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } {
|
||||
parse(body: unknown): {
|
||||
value?: ProtocolRequestEnvelope<'anthropic/messages', ParsedDownstreamChatRequest>;
|
||||
error?: { statusCode: number; payload: unknown };
|
||||
} {
|
||||
const rawBody = isRecord(body) ? body : null;
|
||||
const inboundValidation = rawBody ? sanitizeAnthropicInboundBody(rawBody) : null;
|
||||
if (inboundValidation?.error) {
|
||||
@@ -145,12 +149,25 @@ export const anthropicMessagesInbound = {
|
||||
|
||||
const effectiveBody = inboundValidation?.sanitizedBody ?? body;
|
||||
const parsed = parseDownstreamChatRequest(effectiveBody, 'claude');
|
||||
if (parsed.error || !parsed.value) return parsed;
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return { error: invalidRequest('invalid messages request') };
|
||||
}
|
||||
|
||||
if (inboundValidation?.sanitizedBody) {
|
||||
parsed.value.claudeOriginalBody = inboundValidation.sanitizedBody;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
return {
|
||||
value: createProtocolRequestEnvelope({
|
||||
protocol: 'anthropic/messages',
|
||||
model: parsed.value.requestedModel,
|
||||
stream: parsed.value.isStream,
|
||||
rawBody: body,
|
||||
parsed: parsed.value,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js';
|
||||
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
|
||||
import { anthropicMessagesInbound } from './inbound.js';
|
||||
import { anthropicMessagesOutbound } from './outbound.js';
|
||||
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
|
||||
@@ -22,6 +23,7 @@ export const anthropicMessagesTransformer = {
|
||||
stream: anthropicMessagesStream,
|
||||
usage: anthropicMessagesUsage,
|
||||
compatibility: {
|
||||
createEndpointStrategy: createChatEndpointStrategy,
|
||||
shouldRetryNormalizedBody: shouldRetryNormalizedMessagesBody,
|
||||
isMessagesRequiredError,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { DownstreamFormat } from './normalized.js';
|
||||
import type { EndpointAttemptContext, EndpointRecoverResult } from '../../routes/proxy/endpointFlow.js';
|
||||
import {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
isEndpointDispatchDeniedError,
|
||||
isEndpointDowngradeError,
|
||||
isUnsupportedMediaTypeError,
|
||||
promoteResponsesCandidateAfterLegacyChatError,
|
||||
type CompatibilityEndpoint,
|
||||
} from './endpointCompatibility.js';
|
||||
import {
|
||||
isMessagesRequiredError,
|
||||
shouldRetryNormalizedMessagesBody,
|
||||
} from '../anthropic/messages/compatibility.js';
|
||||
|
||||
type CompatibilityRequest = {
|
||||
endpoint: CompatibilityEndpoint;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type UpstreamResponse = Exclude<EndpointRecoverResult, null>['upstream'];
|
||||
|
||||
type CreateChatEndpointStrategyInput = {
|
||||
downstreamFormat: DownstreamFormat;
|
||||
endpointCandidates: CompatibilityEndpoint[];
|
||||
modelName: string;
|
||||
requestedModelHint: string;
|
||||
sitePlatform?: string | null;
|
||||
isStream: boolean;
|
||||
buildRequest: (input: {
|
||||
endpoint: CompatibilityEndpoint;
|
||||
forceNormalizeClaudeBody?: boolean;
|
||||
}) => CompatibilityRequest;
|
||||
dispatchRequest: (
|
||||
request: CompatibilityRequest,
|
||||
targetUrl?: string,
|
||||
) => Promise<UpstreamResponse>;
|
||||
};
|
||||
|
||||
export function createChatEndpointStrategy(input: CreateChatEndpointStrategyInput) {
|
||||
return {
|
||||
async tryRecover(ctx: EndpointAttemptContext): Promise<EndpointRecoverResult> {
|
||||
if (shouldRetryNormalizedMessagesBody({
|
||||
downstreamFormat: input.downstreamFormat,
|
||||
endpointPath: ctx.request.path,
|
||||
status: ctx.response.status,
|
||||
upstreamErrorText: ctx.rawErrText,
|
||||
})) {
|
||||
const normalizedClaudeRequest = input.buildRequest({
|
||||
endpoint: ctx.request.endpoint,
|
||||
forceNormalizeClaudeBody: true,
|
||||
});
|
||||
const normalizedResponse = await input.dispatchRequest(normalizedClaudeRequest);
|
||||
|
||||
if (normalizedResponse.ok) {
|
||||
return {
|
||||
upstream: normalizedResponse,
|
||||
upstreamPath: normalizedClaudeRequest.path,
|
||||
};
|
||||
}
|
||||
|
||||
ctx.request = normalizedClaudeRequest;
|
||||
ctx.response = normalizedResponse;
|
||||
ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error');
|
||||
}
|
||||
|
||||
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minimalHeaders = buildMinimalJsonHeadersForCompatibility({
|
||||
headers: ctx.request.headers,
|
||||
endpoint: ctx.request.endpoint,
|
||||
stream: input.isStream,
|
||||
});
|
||||
const normalizedCurrentHeaders = Object.fromEntries(
|
||||
Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minimalRequest = {
|
||||
...ctx.request,
|
||||
headers: minimalHeaders,
|
||||
};
|
||||
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 {
|
||||
promoteResponsesCandidateAfterLegacyChatError(input.endpointCandidates, {
|
||||
status: ctx.response.status,
|
||||
upstreamErrorText: ctx.rawErrText,
|
||||
downstreamFormat: input.downstreamFormat,
|
||||
sitePlatform: input.sitePlatform,
|
||||
modelName: input.modelName,
|
||||
requestedModelHint: input.requestedModelHint,
|
||||
currentEndpoint: ctx.request.endpoint,
|
||||
});
|
||||
return (
|
||||
ctx.response.status >= 500
|
||||
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
||||
|| isMessagesRequiredError(ctx.rawErrText)
|
||||
|| isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createStreamTransformContext, normalizeUpstreamStreamEvent } from './chatFormatsCore.js';
|
||||
|
||||
describe('chatFormatsCore inline think parsing', () => {
|
||||
it('tracks split think tags across stream chunks', () => {
|
||||
const context = createStreamTransformContext('gpt-test');
|
||||
|
||||
expect(normalizeUpstreamStreamEvent({
|
||||
id: 'chatcmpl-split-think',
|
||||
model: 'gpt-test',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { role: 'assistant' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}, context, 'gpt-test')).toMatchObject({
|
||||
role: 'assistant',
|
||||
});
|
||||
|
||||
const openingFragment = normalizeUpstreamStreamEvent({
|
||||
id: 'chatcmpl-split-think',
|
||||
model: 'gpt-test',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: '<thin' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}, context, 'gpt-test');
|
||||
expect(openingFragment.contentDelta).toBeUndefined();
|
||||
expect(openingFragment.reasoningDelta).toBeUndefined();
|
||||
|
||||
expect(normalizeUpstreamStreamEvent({
|
||||
id: 'chatcmpl-split-think',
|
||||
model: 'gpt-test',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'k>plan ' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}, context, 'gpt-test')).toMatchObject({
|
||||
reasoningDelta: 'plan ',
|
||||
});
|
||||
|
||||
expect(normalizeUpstreamStreamEvent({
|
||||
id: 'chatcmpl-split-think',
|
||||
model: 'gpt-test',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'quietly</th' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}, context, 'gpt-test')).toMatchObject({
|
||||
reasoningDelta: 'quietly',
|
||||
});
|
||||
|
||||
expect(normalizeUpstreamStreamEvent({
|
||||
id: 'chatcmpl-split-think',
|
||||
model: 'gpt-test',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'ink>visible answer' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}, context, 'gpt-test')).toMatchObject({
|
||||
contentDelta: 'visible answer',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import {
|
||||
decodeAnthropicReasoningSignature,
|
||||
} from './reasoningTransport.js';
|
||||
import {
|
||||
consumeThinkTaggedText,
|
||||
createThinkTagParserState,
|
||||
extractInlineThinkTags,
|
||||
type ThinkTagParserState,
|
||||
} from './thinkTagParser.js';
|
||||
|
||||
export type DownstreamFormat = 'openai' | 'claude';
|
||||
|
||||
@@ -16,6 +22,7 @@ export type StreamTransformContext = {
|
||||
roleSent: boolean;
|
||||
doneSent: boolean;
|
||||
toolCalls: Record<number, { id?: string; name?: string; arguments?: string }>;
|
||||
thinkTagParser: ThinkTagParserState;
|
||||
};
|
||||
|
||||
export type ClaudeDownstreamContext = {
|
||||
@@ -102,8 +109,6 @@ function textFromPart(part: unknown): string {
|
||||
if (typeof part.output_text === 'string') return part.output_text;
|
||||
if (typeof part.completion === 'string') return part.completion;
|
||||
if (typeof part.partial_json === 'string') return part.partial_json;
|
||||
if (typeof part.reasoning_content === 'string') return part.reasoning_content;
|
||||
if (typeof part.reasoning === 'string') return part.reasoning;
|
||||
|
||||
if (Array.isArray(part.content)) {
|
||||
return part.content.map((item) => textFromPart(item)).join('');
|
||||
@@ -118,13 +123,15 @@ function textFromPart(part: unknown): string {
|
||||
}
|
||||
|
||||
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 (typeof item === 'string') {
|
||||
contentParts.push(item);
|
||||
const parsedString = extractInlineThinkTags(item);
|
||||
if (parsedString.content) contentParts.push(parsedString.content);
|
||||
if (parsedString.reasoning) reasoningParts.push(parsedString.reasoning);
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
@@ -143,8 +150,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = textFromPart(item);
|
||||
if (text) contentParts.push(text);
|
||||
const parsedText = extractInlineThinkTags(textFromPart(item));
|
||||
if (parsedText.content) contentParts.push(parsedText.content);
|
||||
if (parsedText.reasoning) reasoningParts.push(parsedText.reasoning);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -159,9 +167,34 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
|
||||
return extractTextAndReasoning(value.parts);
|
||||
}
|
||||
|
||||
const directReasoning = joinNonEmpty([
|
||||
typeof value.reasoning_content === 'string' ? value.reasoning_content : '',
|
||||
typeof value.reasoning === 'string' ? value.reasoning : '',
|
||||
typeof value.thinking === 'string' ? value.thinking : '',
|
||||
]);
|
||||
const parsedText = extractInlineThinkTags(textFromPart(value));
|
||||
|
||||
return {
|
||||
content: textFromPart(value),
|
||||
reasoning: '',
|
||||
content: parsedText.content,
|
||||
reasoning: joinNonEmpty([directReasoning, parsedText.reasoning]),
|
||||
};
|
||||
}
|
||||
|
||||
function extractStreamingTextAndReasoning(
|
||||
value: unknown,
|
||||
thinkTagParser: ThinkTagParserState,
|
||||
): { content: string; reasoning: string } {
|
||||
const parsed = extractTextAndReasoning(value);
|
||||
if (!parsed.content) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const streamed = consumeThinkTaggedText(thinkTagParser, parsed.content);
|
||||
return {
|
||||
content: streamed.content,
|
||||
reasoning: [parsed.reasoning, streamed.reasoning]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,6 +251,7 @@ export function createStreamTransformContext(modelName: string): StreamTransform
|
||||
roleSent: false,
|
||||
doneSent: false,
|
||||
toolCalls: {},
|
||||
thinkTagParser: createThinkTagParserState(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,10 +288,18 @@ function extractAssistantContent(choice: any): string {
|
||||
const content = extractTextAndReasoning(choice?.content).content;
|
||||
if (content) return content;
|
||||
|
||||
if (typeof choice?.text === 'string' && choice.text.length > 0) return choice.text;
|
||||
if (typeof choice?.completion === 'string' && choice.completion.length > 0) return choice.completion;
|
||||
if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) return choice.output_text;
|
||||
if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) return choice.delta.content;
|
||||
if (typeof choice?.text === 'string' && choice.text.length > 0) {
|
||||
return extractInlineThinkTags(choice.text).content;
|
||||
}
|
||||
if (typeof choice?.completion === 'string' && choice.completion.length > 0) {
|
||||
return extractInlineThinkTags(choice.completion).content;
|
||||
}
|
||||
if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) {
|
||||
return extractInlineThinkTags(choice.output_text).content;
|
||||
}
|
||||
if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) {
|
||||
return extractInlineThinkTags(choice.delta.content).content;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
@@ -279,6 +321,11 @@ function extractAssistantReasoning(choice: any): string {
|
||||
const nested = extractTextAndReasoning(choice?.content).reasoning;
|
||||
if (nested) return nested;
|
||||
|
||||
if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) {
|
||||
const parsedDelta = extractInlineThinkTags(choice.delta.content);
|
||||
if (parsedDelta.reasoning) return parsedDelta.reasoning;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -742,17 +789,19 @@ export function normalizeUpstreamStreamEvent(
|
||||
|
||||
const choice = payload.choices[0] ?? {};
|
||||
const delta = isRecord(choice?.delta) ? choice.delta : {};
|
||||
const deltaParsed = extractTextAndReasoning(delta.content ?? delta);
|
||||
const deltaParsed = extractStreamingTextAndReasoning(delta.content ?? delta, context.thinkTagParser);
|
||||
const messageParsed = extractStreamingTextAndReasoning(choice?.message?.content ?? '', context.thinkTagParser);
|
||||
|
||||
const rawContentDelta =
|
||||
deltaParsed.content
|
||||
|| (typeof choice?.message?.content === 'string' ? choice.message.content : '')
|
||||
|| messageParsed.content
|
||||
|| '';
|
||||
|
||||
const reasoningDelta =
|
||||
(typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '')
|
||||
|| (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '')
|
||||
|| deltaParsed.reasoning
|
||||
|| messageParsed.reasoning
|
||||
|| '';
|
||||
const reasoningSignature = isNonEmptyString((delta as any).reasoning_signature)
|
||||
? (delta as any).reasoning_signature
|
||||
@@ -810,11 +859,10 @@ export function normalizeUpstreamStreamEvent(
|
||||
|
||||
const type = typeof payload.type === 'string' ? payload.type : '';
|
||||
if (type.startsWith('response.output_text')) {
|
||||
const deltaText = typeof payload.delta === 'string'
|
||||
? payload.delta
|
||||
: extractTextAndReasoning(payload.delta).content;
|
||||
const parsed = extractStreamingTextAndReasoning(payload.delta, context.thinkTagParser);
|
||||
return {
|
||||
contentDelta: deltaText || undefined,
|
||||
contentDelta: parsed.content || undefined,
|
||||
reasoningDelta: parsed.reasoning || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -975,7 +1023,7 @@ export function normalizeUpstreamStreamEvent(
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = extractTextAndReasoning(payload.content_block);
|
||||
const parsed = extractStreamingTextAndReasoning(payload.content_block, context.thinkTagParser);
|
||||
return {
|
||||
contentDelta: parsed.content || undefined,
|
||||
reasoningDelta: parsed.reasoning || undefined,
|
||||
@@ -985,7 +1033,7 @@ export function normalizeUpstreamStreamEvent(
|
||||
if (type === 'content_block_delta') {
|
||||
const delta = isRecord(payload.delta) ? payload.delta : {};
|
||||
const deltaType = typeof delta.type === 'string' ? delta.type : '';
|
||||
const parsed = extractTextAndReasoning(delta);
|
||||
const parsed = extractStreamingTextAndReasoning(delta, context.thinkTagParser);
|
||||
|
||||
if (deltaType === 'input_json_delta') {
|
||||
const index = (
|
||||
@@ -1029,7 +1077,10 @@ export function normalizeUpstreamStreamEvent(
|
||||
|
||||
if (Array.isArray(payload.candidates)) {
|
||||
const candidate = payload.candidates[0] || {};
|
||||
const parsed = extractTextAndReasoning((candidate as any).content?.parts || (candidate as any).content);
|
||||
const parsed = extractStreamingTextAndReasoning(
|
||||
(candidate as any).content?.parts || (candidate as any).content,
|
||||
context.thinkTagParser,
|
||||
);
|
||||
|
||||
if (isNonEmptyString((payload as any).modelVersion)) {
|
||||
context.model = (payload as any).modelVersion;
|
||||
@@ -1044,7 +1095,7 @@ export function normalizeUpstreamStreamEvent(
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = extractTextAndReasoning(payload);
|
||||
const fallback = extractStreamingTextAndReasoning(payload, context.thinkTagParser);
|
||||
return {
|
||||
contentDelta: fallback.content || undefined,
|
||||
reasoningDelta: fallback.reasoning || undefined,
|
||||
@@ -1055,15 +1106,17 @@ function buildOpenAiStreamChunk(
|
||||
context: StreamTransformContext,
|
||||
event: NormalizedStreamEvent,
|
||||
): Record<string, unknown> | null {
|
||||
const normalizedContentDelta = event.contentDelta || '';
|
||||
const normalizedReasoningDelta = event.reasoningDelta || '';
|
||||
const delta: Record<string, unknown> = {};
|
||||
const isInitialAssistantRoleOnlyEvent = (
|
||||
!context.roleSent
|
||||
&& event.role === 'assistant'
|
||||
&& !event.contentDelta
|
||||
&& !event.reasoningDelta
|
||||
&& !normalizedContentDelta
|
||||
&& !normalizedReasoningDelta
|
||||
);
|
||||
|
||||
if (!context.roleSent && (event.role === 'assistant' || event.contentDelta || event.reasoningDelta)) {
|
||||
if (!context.roleSent && (event.role === 'assistant' || normalizedContentDelta || normalizedReasoningDelta)) {
|
||||
delta.role = 'assistant';
|
||||
context.roleSent = true;
|
||||
} else if (event.role === 'assistant') {
|
||||
@@ -1071,12 +1124,12 @@ function buildOpenAiStreamChunk(
|
||||
context.roleSent = true;
|
||||
}
|
||||
|
||||
if (event.contentDelta) {
|
||||
delta.content = event.contentDelta;
|
||||
if (normalizedContentDelta) {
|
||||
delta.content = normalizedContentDelta;
|
||||
}
|
||||
|
||||
if (event.reasoningDelta) {
|
||||
delta.reasoning_content = event.reasoningDelta;
|
||||
if (normalizedReasoningDelta) {
|
||||
delta.reasoning_content = normalizedReasoningDelta;
|
||||
}
|
||||
|
||||
if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { DownstreamFormat } from './normalized.js';
|
||||
|
||||
export type CompatibilityEndpoint = 'chat' | 'messages' | 'responses';
|
||||
export type CompatibilityEndpointPreference = DownstreamFormat | 'responses';
|
||||
|
||||
type PreferResponsesAfterLegacyChatErrorInput = {
|
||||
status: number;
|
||||
upstreamErrorText?: string | null;
|
||||
downstreamFormat: CompatibilityEndpointPreference;
|
||||
sitePlatform?: string | null;
|
||||
modelName?: string | null;
|
||||
requestedModelHint?: string | null;
|
||||
currentEndpoint?: CompatibilityEndpoint | null;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizePlatformName(platform: unknown): string {
|
||||
return asTrimmedString(platform).toLowerCase();
|
||||
}
|
||||
|
||||
function isClaudeFamilyModel(modelName: string): boolean {
|
||||
const normalized = asTrimmedString(modelName).toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return normalized === 'claude' || normalized.startsWith('claude-') || normalized.includes('claude');
|
||||
}
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeHeaderMap(headers: Record<string, string>): Record<string, string> {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const value = headerValueToString(rawValue);
|
||||
if (!value) continue;
|
||||
normalized[key] = value;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildMinimalJsonHeadersForCompatibility(input: {
|
||||
headers: Record<string, string>;
|
||||
endpoint: CompatibilityEndpoint;
|
||||
stream: boolean;
|
||||
}): Record<string, string> {
|
||||
const source = normalizeHeaderMap(input.headers);
|
||||
const minimal: Record<string, string> = {};
|
||||
|
||||
if (source.authorization) minimal.authorization = source.authorization;
|
||||
if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key'];
|
||||
|
||||
if (input.endpoint === 'messages') {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!key.startsWith('anthropic-')) continue;
|
||||
minimal[key] = value;
|
||||
}
|
||||
if (!minimal['anthropic-version']) {
|
||||
minimal['anthropic-version'] = '2023-06-01';
|
||||
}
|
||||
}
|
||||
|
||||
minimal['content-type'] = 'application/json';
|
||||
minimal.accept = input.stream ? 'text/event-stream' : 'application/json';
|
||||
return minimal;
|
||||
}
|
||||
|
||||
export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean {
|
||||
if (status < 400) return false;
|
||||
if (status !== 400 && status !== 415) return false;
|
||||
const text = (upstreamErrorText || '').toLowerCase();
|
||||
if (!text) return status === 415;
|
||||
|
||||
return (
|
||||
text.includes('unsupported media type')
|
||||
|| text.includes("only 'application/json' is allowed")
|
||||
|| text.includes('only "application/json" is allowed')
|
||||
|| text.includes('application/json')
|
||||
|| text.includes('content-type')
|
||||
);
|
||||
}
|
||||
|
||||
export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?: string | null): boolean {
|
||||
if (status !== 403) return false;
|
||||
const text = (upstreamErrorText || '').toLowerCase();
|
||||
if (!text) return false;
|
||||
|
||||
return (
|
||||
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(upstreamErrorText || '')
|
||||
|| text.includes('dispatch denied')
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldPreferResponsesAfterLegacyChatError(
|
||||
input: PreferResponsesAfterLegacyChatErrorInput,
|
||||
): boolean {
|
||||
if (input.status < 400) return false;
|
||||
if (input.downstreamFormat !== 'openai') return false;
|
||||
if (input.currentEndpoint !== 'chat') return false;
|
||||
|
||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||
if (sitePlatform === 'openai' || sitePlatform === 'claude' || sitePlatform === 'gemini' || sitePlatform === 'anyrouter') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const modelName = asTrimmedString(input.modelName);
|
||||
const requestedModelHint = asTrimmedString(input.requestedModelHint);
|
||||
if (isClaudeFamilyModel(modelName) || isClaudeFamilyModel(requestedModelHint)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = (input.upstreamErrorText || '').toLowerCase();
|
||||
return (
|
||||
text.includes('unsupported legacy protocol')
|
||||
&& text.includes('/v1/chat/completions')
|
||||
&& text.includes('/v1/responses')
|
||||
);
|
||||
}
|
||||
|
||||
export function promoteResponsesCandidateAfterLegacyChatError(
|
||||
endpointCandidates: CompatibilityEndpoint[],
|
||||
input: PreferResponsesAfterLegacyChatErrorInput,
|
||||
): void {
|
||||
if (!shouldPreferResponsesAfterLegacyChatError(input)) return;
|
||||
|
||||
const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint);
|
||||
const responsesIndex = endpointCandidates.indexOf('responses');
|
||||
if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return;
|
||||
|
||||
endpointCandidates.splice(responsesIndex, 1);
|
||||
endpointCandidates.splice(currentIndex + 1, 0, 'responses');
|
||||
}
|
||||
|
||||
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
|
||||
if (status < 400) return false;
|
||||
const text = (upstreamErrorText || '').toLowerCase();
|
||||
if (status === 404 || status === 405 || status === 415 || status === 501) return true;
|
||||
if (!text) return false;
|
||||
|
||||
let parsedCode = '';
|
||||
let parsedType = '';
|
||||
let parsedMessage = '';
|
||||
try {
|
||||
const parsed = JSON.parse(upstreamErrorText || '{}') as Record<string, unknown>;
|
||||
const error = (parsed.error && typeof parsed.error === 'object')
|
||||
? parsed.error as Record<string, unknown>
|
||||
: parsed;
|
||||
parsedCode = asTrimmedString(error.code).toLowerCase();
|
||||
parsedType = asTrimmedString(error.type).toLowerCase();
|
||||
parsedMessage = asTrimmedString(error.message).toLowerCase();
|
||||
} catch {
|
||||
parsedCode = '';
|
||||
parsedType = '';
|
||||
parsedMessage = '';
|
||||
}
|
||||
|
||||
return (
|
||||
isEndpointDispatchDeniedError(status, upstreamErrorText)
|
||||
|| text.includes('convert_request_failed')
|
||||
|| text.includes('not found')
|
||||
|| text.includes('unknown endpoint')
|
||||
|| text.includes('unsupported endpoint')
|
||||
|| text.includes('unsupported path')
|
||||
|| text.includes('unrecognized request url')
|
||||
|| text.includes('no route matched')
|
||||
|| text.includes('does not exist')
|
||||
|| text.includes('openai_error')
|
||||
|| text.includes('upstream_error')
|
||||
|| text.includes('bad_response_status_code')
|
||||
|| text.includes('unsupported media type')
|
||||
|| text.includes("only 'application/json' is allowed")
|
||||
|| text.includes('only "application/json" is allowed')
|
||||
|| (status === 400 && text.includes('unsupported'))
|
||||
|| text.includes('not implemented')
|
||||
|| text.includes('api not implemented')
|
||||
|| text.includes('unsupported legacy protocol')
|
||||
|| parsedCode === 'convert_request_failed'
|
||||
|| parsedCode === 'not_found'
|
||||
|| parsedCode === 'endpoint_not_found'
|
||||
|| parsedCode === 'unknown_endpoint'
|
||||
|| parsedCode === 'unsupported_endpoint'
|
||||
|| parsedCode === 'bad_response_status_code'
|
||||
|| parsedCode === 'openai_error'
|
||||
|| parsedCode === 'upstream_error'
|
||||
|| parsedType === 'not_found_error'
|
||||
|| parsedType === 'invalid_request_error'
|
||||
|| parsedType === 'unsupported_endpoint'
|
||||
|| parsedType === 'unsupported_path'
|
||||
|| parsedType === 'bad_response_status_code'
|
||||
|| parsedType === 'openai_error'
|
||||
|| parsedType === 'upstream_error'
|
||||
|| parsedMessage.includes('unknown endpoint')
|
||||
|| parsedMessage.includes('unsupported endpoint')
|
||||
|| parsedMessage.includes('unsupported path')
|
||||
|| parsedMessage.includes('unrecognized request url')
|
||||
|| parsedMessage.includes('no route matched')
|
||||
|| parsedMessage.includes('does not exist')
|
||||
|| parsedMessage.includes('bad_response_status_code')
|
||||
|| parsedMessage === 'openai_error'
|
||||
|| parsedMessage === 'upstream_error'
|
||||
|| parsedMessage.includes('unsupported media type')
|
||||
|| parsedMessage.includes("only 'application/json' is allowed")
|
||||
|| parsedMessage.includes('only "application/json" is allowed')
|
||||
|| (
|
||||
status === 400
|
||||
&& parsedCode === 'invalid_request'
|
||||
&& parsedType === 'new_api_error'
|
||||
&& (parsedMessage.includes('claude code cli') || text.includes('claude code cli'))
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureBase64DataUrl,
|
||||
inferInputFileMimeType,
|
||||
normalizeInputFileBlock,
|
||||
toAnthropicDocumentBlock,
|
||||
toOpenAiChatFileBlock,
|
||||
toResponsesInputFileBlock,
|
||||
} from './inputFile.js';
|
||||
|
||||
describe('shared input file helpers', () => {
|
||||
it('preserves existing data URLs when building Responses input_file blocks', () => {
|
||||
expect(toResponsesInputFileBlock({
|
||||
fileData: 'data:application/pdf;base64,JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
})).toEqual({
|
||||
type: 'input_file',
|
||||
file_data: 'data:application/pdf;base64,JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('infers mime type from filename when wrapping raw base64 for Responses blocks', () => {
|
||||
expect(toResponsesInputFileBlock({
|
||||
fileData: 'JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
})).toEqual({
|
||||
type: 'input_file',
|
||||
file_data: 'data:application/pdf;base64,JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers file_data over file_url and file_id when serializing Responses blocks', () => {
|
||||
expect(toResponsesInputFileBlock({
|
||||
fileData: 'JVBERi0x',
|
||||
fileUrl: 'https://example.com/brief.pdf',
|
||||
fileId: 'file_123',
|
||||
filename: 'brief.pdf',
|
||||
})).toEqual({
|
||||
type: 'input_file',
|
||||
file_data: 'data:application/pdf;base64,JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips data URL wrappers for OpenAI chat file blocks while preserving mime type', () => {
|
||||
expect(toOpenAiChatFileBlock({
|
||||
fileData: 'data:application/pdf;base64,JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
})).toEqual({
|
||||
type: 'file',
|
||||
file: {
|
||||
file_data: 'JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates anthropic document blocks from inline file data', () => {
|
||||
expect(toAnthropicDocumentBlock({
|
||||
fileData: 'data:text/plain;base64,SGVsbG8=',
|
||||
filename: 'notes.txt',
|
||||
hadDataUrl: true,
|
||||
})).toEqual({
|
||||
type: 'document',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'text/plain',
|
||||
data: 'SGVsbG8=',
|
||||
},
|
||||
title: 'notes.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('infers common mime types from filenames', () => {
|
||||
expect(inferInputFileMimeType({ filename: 'notes.md', mimeType: null })).toBe('text/markdown');
|
||||
expect(inferInputFileMimeType({ filename: 'photo.jpeg', mimeType: null })).toBe('image/jpeg');
|
||||
expect(inferInputFileMimeType({ filename: 'voice.mp3', mimeType: null })).toBe('audio/mpeg');
|
||||
});
|
||||
|
||||
it('leaves raw base64 unchanged when mime type is unknown', () => {
|
||||
expect(ensureBase64DataUrl('YWJj', null)).toBe('YWJj');
|
||||
});
|
||||
|
||||
it('prefers inline data when input_file blocks also carry file_url or file_id', () => {
|
||||
expect(normalizeInputFileBlock({
|
||||
type: 'input_file',
|
||||
file_data: 'data:application/pdf;base64,JVBERi0x',
|
||||
file_url: 'https://example.com/brief.pdf',
|
||||
file_id: 'file_123',
|
||||
filename: 'brief.pdf',
|
||||
})).toEqual({
|
||||
sourceType: 'input_file',
|
||||
fileData: 'data:application/pdf;base64,JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
hadDataUrl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers inline data when file wrapper blocks also carry file_url or file_id', () => {
|
||||
expect(normalizeInputFileBlock({
|
||||
type: 'file',
|
||||
file: {
|
||||
file_data: 'JVBERi0x',
|
||||
file_url: 'https://example.com/brief.pdf',
|
||||
file_id: 'file_123',
|
||||
filename: 'brief.pdf',
|
||||
},
|
||||
})).toEqual({
|
||||
sourceType: 'file',
|
||||
fileData: 'JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: null,
|
||||
hadDataUrl: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,20 @@ function splitBase64DataUrl(value: string): { mimeType: string; data: string } |
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileSourceSelection(
|
||||
fileData: string,
|
||||
fileUrl: string,
|
||||
fileId: string,
|
||||
): { fileData: string; fileUrl: string; fileId: string } {
|
||||
if (fileData) {
|
||||
return { fileData, fileUrl: '', fileId: '' };
|
||||
}
|
||||
if (fileUrl) {
|
||||
return { fileData: '', fileUrl, fileId: '' };
|
||||
}
|
||||
return { fileData: '', fileUrl: '', fileId };
|
||||
}
|
||||
|
||||
export function ensureBase64DataUrl(fileData: string, mimeType?: string | null): string {
|
||||
const trimmedData = asTrimmedString(fileData);
|
||||
if (!trimmedData) return trimmedData;
|
||||
@@ -58,9 +72,14 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type === 'input_file') {
|
||||
const fileId = asTrimmedString(item.file_id);
|
||||
const fileData = asTrimmedString(item.file_data);
|
||||
const fileUrl = asTrimmedString(item.file_url);
|
||||
const selectedSource = normalizeFileSourceSelection(
|
||||
asTrimmedString(item.file_data),
|
||||
asTrimmedString(item.file_url),
|
||||
asTrimmedString(item.file_id),
|
||||
);
|
||||
const fileId = selectedSource.fileId;
|
||||
const fileData = selectedSource.fileData;
|
||||
const fileUrl = selectedSource.fileUrl;
|
||||
const filename = asTrimmedString(item.filename);
|
||||
let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null;
|
||||
if (!fileId && !fileData && !fileUrl) return null;
|
||||
@@ -81,9 +100,14 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
||||
|
||||
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 fileUrl = asTrimmedString(file.file_url ?? item.file_url);
|
||||
const selectedSource = normalizeFileSourceSelection(
|
||||
asTrimmedString(file.file_data ?? item.file_data),
|
||||
asTrimmedString(file.file_url ?? item.file_url),
|
||||
asTrimmedString(file.file_id ?? item.file_id),
|
||||
);
|
||||
const fileId = selectedSource.fileId;
|
||||
const fileData = selectedSource.fileData;
|
||||
const fileUrl = selectedSource.fileUrl;
|
||||
const filename = asTrimmedString(file.filename ?? item.filename);
|
||||
let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null;
|
||||
if (!fileId && !fileData && !fileUrl) return null;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
type PulledEventBatch<TEvent> = {
|
||||
events: TEvent[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type ProxyStreamReader = {
|
||||
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
||||
cancel(reason?: unknown): Promise<unknown>;
|
||||
releaseLock(): void;
|
||||
};
|
||||
|
||||
type ProxyStreamLifecycleInput<TEvent> = {
|
||||
reader: ProxyStreamReader | null | undefined;
|
||||
response: { end(): void };
|
||||
pullEvents(buffer: string): PulledEventBatch<TEvent>;
|
||||
handleEvent(event: TEvent): Promise<boolean | void> | boolean | void;
|
||||
onEof?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function createProxyStreamLifecycle<TEvent>(input: ProxyStreamLifecycleInput<TEvent>) {
|
||||
const flushBuffer = async (buffer: string): Promise<{ rest: string; stop: boolean }> => {
|
||||
const pulled = input.pullEvents(buffer);
|
||||
for (const event of pulled.events) {
|
||||
if (await input.handleEvent(event)) {
|
||||
return {
|
||||
rest: pulled.rest,
|
||||
stop: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rest: pulled.rest,
|
||||
stop: false,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async run(): Promise<void> {
|
||||
const reader = input.reader;
|
||||
if (!reader) {
|
||||
try {
|
||||
await input.onEof?.();
|
||||
} finally {
|
||||
input.response.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
let shouldStop = false;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
sseBuffer += decoder.decode(value, { stream: true });
|
||||
const flushed = await flushBuffer(sseBuffer);
|
||||
sseBuffer = flushed.rest;
|
||||
if (!flushed.stop) continue;
|
||||
|
||||
shouldStop = true;
|
||||
await reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
|
||||
if (!shouldStop) {
|
||||
sseBuffer += decoder.decode();
|
||||
if (sseBuffer.trim().length > 0) {
|
||||
const flushed = await flushBuffer(`${sseBuffer}\n\n`);
|
||||
sseBuffer = flushed.rest;
|
||||
shouldStop = flushed.stop;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldStop) {
|
||||
await input.onEof?.();
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
input.response.end();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createProtocolRequestEnvelope,
|
||||
createProtocolResponseEnvelope,
|
||||
createProtocolStreamEnvelope,
|
||||
} from './protocolModel.js';
|
||||
|
||||
describe('protocolModel', () => {
|
||||
it('creates protocol request envelopes with shared top-level fields', () => {
|
||||
const envelope = createProtocolRequestEnvelope({
|
||||
protocol: 'openai/chat',
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
rawBody: { model: 'gpt-5', stream: true },
|
||||
parsed: { requestedModel: 'gpt-5', isStream: true },
|
||||
metadata: { serviceTier: 'priority' },
|
||||
});
|
||||
|
||||
expect(envelope).toEqual({
|
||||
protocol: 'openai/chat',
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
rawBody: { model: 'gpt-5', stream: true },
|
||||
parsed: { requestedModel: 'gpt-5', isStream: true },
|
||||
metadata: { serviceTier: 'priority' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates protocol response and stream envelopes without losing metadata', () => {
|
||||
const response = createProtocolResponseEnvelope({
|
||||
protocol: 'openai/responses',
|
||||
model: 'gpt-5',
|
||||
final: {
|
||||
id: 'resp_1',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
content: 'done',
|
||||
reasoningContent: 'think',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
},
|
||||
metadata: { citations: ['https://example.com'] },
|
||||
});
|
||||
const stream = createProtocolStreamEnvelope({
|
||||
protocol: 'openai/responses',
|
||||
model: 'gpt-5',
|
||||
event: {
|
||||
contentDelta: 'done',
|
||||
finishReason: 'stop',
|
||||
},
|
||||
metadata: { citations: ['https://example.com'] },
|
||||
});
|
||||
|
||||
expect(response.metadata).toEqual({ citations: ['https://example.com'] });
|
||||
expect(stream.metadata).toEqual({ citations: ['https://example.com'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
NormalizedFinalResponse,
|
||||
NormalizedStreamEvent,
|
||||
} from './normalized.js';
|
||||
|
||||
export type TransformerProtocol =
|
||||
| 'openai/chat'
|
||||
| 'anthropic/messages'
|
||||
| 'openai/responses'
|
||||
| 'gemini/generate-content';
|
||||
|
||||
export type ProtocolRequestEnvelope<
|
||||
TProtocol extends TransformerProtocol = TransformerProtocol,
|
||||
TParsed = unknown,
|
||||
TMetadata = unknown,
|
||||
> = {
|
||||
protocol: TProtocol;
|
||||
model: string;
|
||||
stream: boolean;
|
||||
rawBody: unknown;
|
||||
parsed: TParsed;
|
||||
metadata?: TMetadata;
|
||||
};
|
||||
|
||||
export type ProtocolResponseEnvelope<
|
||||
TProtocol extends TransformerProtocol = TransformerProtocol,
|
||||
TFinal extends NormalizedFinalResponse = NormalizedFinalResponse,
|
||||
TMetadata = unknown,
|
||||
> = {
|
||||
protocol: TProtocol;
|
||||
model: string;
|
||||
final: TFinal;
|
||||
usage?: unknown;
|
||||
metadata?: TMetadata;
|
||||
};
|
||||
|
||||
export type ProtocolStreamEnvelope<
|
||||
TProtocol extends TransformerProtocol = TransformerProtocol,
|
||||
TEvent extends NormalizedStreamEvent = NormalizedStreamEvent,
|
||||
TMetadata = unknown,
|
||||
> = {
|
||||
protocol: TProtocol;
|
||||
model: string;
|
||||
event: TEvent;
|
||||
metadata?: TMetadata;
|
||||
};
|
||||
|
||||
export function createProtocolRequestEnvelope<
|
||||
TProtocol extends TransformerProtocol,
|
||||
TParsed,
|
||||
TMetadata = unknown,
|
||||
>(envelope: ProtocolRequestEnvelope<TProtocol, TParsed, TMetadata>): ProtocolRequestEnvelope<TProtocol, TParsed, TMetadata> {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
export function createProtocolResponseEnvelope<
|
||||
TProtocol extends TransformerProtocol,
|
||||
TFinal extends NormalizedFinalResponse,
|
||||
TMetadata = unknown,
|
||||
>(envelope: ProtocolResponseEnvelope<TProtocol, TFinal, TMetadata>): ProtocolResponseEnvelope<TProtocol, TFinal, TMetadata> {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
export function createProtocolStreamEnvelope<
|
||||
TProtocol extends TransformerProtocol,
|
||||
TEvent extends NormalizedStreamEvent,
|
||||
TMetadata = unknown,
|
||||
>(envelope: ProtocolStreamEnvelope<TProtocol, TEvent, TMetadata>): ProtocolStreamEnvelope<TProtocol, TEvent, TMetadata> {
|
||||
return envelope;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
export type ThinkTagParserState = {
|
||||
mode: 'content' | 'reasoning';
|
||||
pending: string;
|
||||
};
|
||||
|
||||
const OPEN_TAG = '<think>';
|
||||
const CLOSE_TAG = '</think>';
|
||||
|
||||
function trailingPrefixLength(value: string, prefix: string): number {
|
||||
const maxLength = Math.min(value.length, prefix.length - 1);
|
||||
for (let length = maxLength; length > 0; length -= 1) {
|
||||
if (prefix.startsWith(value.slice(-length))) {
|
||||
return length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function consumeChunkAgainstTag(
|
||||
source: string,
|
||||
tag: string,
|
||||
target: 'content' | 'reasoning',
|
||||
): { emitted: string; rest: string; matched: boolean } {
|
||||
const sourceLower = source.toLowerCase();
|
||||
const tagIndex = sourceLower.indexOf(tag);
|
||||
if (tagIndex >= 0) {
|
||||
return {
|
||||
emitted: source.slice(0, tagIndex),
|
||||
rest: source.slice(tagIndex + tag.length),
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingLength = trailingPrefixLength(sourceLower, tag);
|
||||
return {
|
||||
emitted: source.slice(0, source.length - pendingLength),
|
||||
rest: source.slice(source.length - pendingLength),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createThinkTagParserState(): ThinkTagParserState {
|
||||
return {
|
||||
mode: 'content',
|
||||
pending: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function consumeThinkTaggedText(
|
||||
state: ThinkTagParserState,
|
||||
chunk: string,
|
||||
): { content: string; reasoning: string } {
|
||||
if (!chunk) {
|
||||
return { content: '', reasoning: '' };
|
||||
}
|
||||
|
||||
let content = '';
|
||||
let reasoning = '';
|
||||
let rest = `${state.pending}${chunk}`;
|
||||
state.pending = '';
|
||||
|
||||
while (rest.length > 0) {
|
||||
const currentTag = state.mode === 'content' ? OPEN_TAG : CLOSE_TAG;
|
||||
const consumed = consumeChunkAgainstTag(rest, currentTag, state.mode);
|
||||
if (state.mode === 'content') {
|
||||
content += consumed.emitted;
|
||||
} else {
|
||||
reasoning += consumed.emitted;
|
||||
}
|
||||
|
||||
if (!consumed.matched) {
|
||||
state.pending = consumed.rest;
|
||||
break;
|
||||
}
|
||||
|
||||
rest = consumed.rest;
|
||||
state.mode = state.mode === 'content' ? 'reasoning' : 'content';
|
||||
}
|
||||
|
||||
return { content, reasoning };
|
||||
}
|
||||
|
||||
export function flushThinkTaggedText(state: ThinkTagParserState): { content: string; reasoning: string } {
|
||||
if (!state.pending) {
|
||||
return { content: '', reasoning: '' };
|
||||
}
|
||||
|
||||
const remainder = state.pending;
|
||||
state.pending = '';
|
||||
return state.mode === 'content'
|
||||
? { content: remainder, reasoning: '' }
|
||||
: { content: '', reasoning: remainder };
|
||||
}
|
||||
|
||||
export function extractInlineThinkTags(text: string): { content: string; reasoning: string } {
|
||||
if (!text) {
|
||||
return { content: '', reasoning: '' };
|
||||
}
|
||||
|
||||
const state = createThinkTagParserState();
|
||||
const consumed = consumeThinkTaggedText(state, text);
|
||||
const flushed = flushThinkTaggedText(state);
|
||||
return {
|
||||
content: `${consumed.content}${flushed.content}`,
|
||||
reasoning: `${consumed.reasoning}${flushed.reasoning}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user