feat: Implement compatibility strategies for responses and chat endpoints

- Added `routeCompatibility.ts` to handle response compatibility for different endpoints.
- Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic.
- Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`.
- Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints.
- Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`.
- Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests.
- Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
cita
2026-03-09 22:48:10 +08:00
parent 15d4b207cc
commit 0f8176cd04
74 changed files with 4645 additions and 1565 deletions
@@ -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,
});
});
});
+30 -6
View File
@@ -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}`,
};
}