Preserve the active worktree snapshot on dev for follow-on integration
The root checkout already contained a large in-progress proxy, transformer, and settings refactor. This commit captures that exact work on a dedicated local dev branch without disturbing the original dirty worktree, so the changes can be continued or split from dev instead of living only as uncommitted state. Constraint: The source checkout was already dirty on another branch, so the move had to preserve that worktree while excluding local .idea and .omx residue from the commit Rejected: Switch the dirty root checkout directly to dev | risked disrupting the original branch and in-progress state Confidence: medium Scope-risk: broad Reversibility: clean Directive: Treat this as a transfer snapshot; fix the failing oauth/accountTokens/normalized/docs test regressions before merge or publication Tested: git diff --check; npm run typecheck; npm run repo:drift-check Not-tested: Clean test pass; npm test currently fails in oauth routes, accountTokens sync, shared normalized helpers, gemini route expectations, architecture-boundaries, and docs vitepress config
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { type DownstreamFormat } from '../../shared/normalized.js';
|
||||
import { inferRequiredEndpointFromProtocolError } from '../../shared/endpointCompatibility.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -61,9 +62,9 @@ export function shouldRetryNormalizedMessagesBody(input: {
|
||||
if (input.downstreamFormat !== 'claude') return false;
|
||||
if (!input.endpointPath.includes('/v1/messages')) return false;
|
||||
if (input.status < 400 || input.status >= 500) return false;
|
||||
return /messages\s+is\s+required/i.test(input.upstreamErrorText);
|
||||
return inferRequiredEndpointFromProtocolError(input.upstreamErrorText) === 'messages';
|
||||
}
|
||||
|
||||
export function isMessagesRequiredError(upstreamErrorText: string): boolean {
|
||||
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
||||
return inferRequiredEndpointFromProtocolError(upstreamErrorText) === 'messages';
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('anthropicMessagesTransformer protocol contract', () => {
|
||||
],
|
||||
metadata: {
|
||||
user_id: 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account__session_11111111-2222-3333-4444-555555555555',
|
||||
metapi_turn_state: 'turn-state-claude-1',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,6 +26,10 @@ describe('anthropicMessagesTransformer protocol contract', () => {
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
continuation: {
|
||||
sessionId: 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account__session_11111111-2222-3333-4444-555555555555',
|
||||
turnState: 'turn-state-claude-1',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -98,12 +103,20 @@ describe('anthropicMessagesTransformer protocol contract', () => {
|
||||
parts: [{ type: 'text', text: 'count these tokens' }],
|
||||
},
|
||||
],
|
||||
continuation: {
|
||||
sessionId: 'session-claude-bridge-1',
|
||||
turnState: 'turn-state-claude-bridge-1',
|
||||
},
|
||||
tools: [{ name: 'lookup', inputSchema: { type: 'object' } }],
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 4096,
|
||||
metadata: {
|
||||
user_id: 'session-claude-bridge-1',
|
||||
metapi_turn_state: 'turn-state-claude-bridge-1',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
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 { convertOpenAiBodyToAnthropicMessagesBody } from './conversion.js';
|
||||
import { anthropicMessagesOutbound } from './outbound.js';
|
||||
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
|
||||
import {
|
||||
buildCanonicalRequestToAnthropicMessagesBody,
|
||||
parseAnthropicMessagesRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
import {
|
||||
anthropicMessagesResponseBridge,
|
||||
buildNormalizedFinalToAnthropicMessagesBody,
|
||||
normalizeAnthropicMessagesFinalToNormalized,
|
||||
} from './responseBridge.js';
|
||||
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './streamBridge.js';
|
||||
import { anthropicMessagesUsage } from './usage.js';
|
||||
import { createAnthropicMessagesAggregateState } from './aggregator.js';
|
||||
import {
|
||||
@@ -21,12 +27,12 @@ export {
|
||||
serializeAnthropicUpstreamFinalAsStream,
|
||||
serializeAnthropicRawSseEvent,
|
||||
syncAnthropicRawStreamStateFromEvent,
|
||||
} from './stream.js';
|
||||
} from './streamBridge.js';
|
||||
|
||||
export const anthropicMessagesTransformer = {
|
||||
protocol: 'anthropic/messages' as const,
|
||||
inbound: anthropicMessagesInbound,
|
||||
outbound: anthropicMessagesOutbound,
|
||||
outbound: anthropicMessagesResponseBridge,
|
||||
stream: anthropicMessagesStream,
|
||||
usage: anthropicMessagesUsage,
|
||||
compatibility: {
|
||||
@@ -41,42 +47,13 @@ export const anthropicMessagesTransformer = {
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = anthropicMessagesInbound.parse(body);
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid messages request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: parsed.value.parsed.upstreamBody,
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
return parseAnthropicMessagesRequestToCanonical(body, ctx);
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream);
|
||||
return buildCanonicalRequestToAnthropicMessagesBody(request);
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof anthropicMessagesInbound.parse> {
|
||||
return anthropicMessagesInbound.parse(body);
|
||||
@@ -88,7 +65,7 @@ export const anthropicMessagesTransformer = {
|
||||
return anthropicMessagesStream.createDownstreamContext();
|
||||
},
|
||||
transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
|
||||
return anthropicMessagesOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
return normalizeAnthropicMessagesFinalToNormalized(payload, modelName, fallbackText);
|
||||
},
|
||||
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent {
|
||||
return anthropicMessagesStream.normalizeEvent(payload, context, modelName);
|
||||
@@ -108,9 +85,9 @@ export const anthropicMessagesTransformer = {
|
||||
},
|
||||
serializeFinalResponse(
|
||||
normalized: NormalizedFinalResponse,
|
||||
usage: Parameters<typeof anthropicMessagesOutbound.serializeFinal>[1],
|
||||
usage: Parameters<typeof buildNormalizedFinalToAnthropicMessagesBody>[1],
|
||||
) {
|
||||
return anthropicMessagesOutbound.serializeFinal(normalized, usage);
|
||||
return buildNormalizedFinalToAnthropicMessagesBody(normalized, usage);
|
||||
},
|
||||
serializeUpstreamFinalAsStream(
|
||||
payload: unknown,
|
||||
@@ -123,7 +100,7 @@ export const anthropicMessagesTransformer = {
|
||||
payload,
|
||||
modelName,
|
||||
fallbackText,
|
||||
anthropicMessagesOutbound.normalizeFinal,
|
||||
normalizeAnthropicMessagesFinalToNormalized,
|
||||
streamContext,
|
||||
claudeContext,
|
||||
);
|
||||
|
||||
@@ -1,181 +1,7 @@
|
||||
import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js';
|
||||
import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js';
|
||||
import { toAnthropicUsagePayload } from './usage.js';
|
||||
|
||||
type AnthropicRecord = Record<string, unknown>;
|
||||
|
||||
type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & {
|
||||
nativeContent?: AnthropicRecord[];
|
||||
stopSequence?: string | null;
|
||||
usagePayload?: AnthropicRecord;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is AnthropicRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseJsonLike(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { value: raw };
|
||||
}
|
||||
}
|
||||
|
||||
function buildClaudeMessageId(sourceId: string): string {
|
||||
if (sourceId.startsWith('msg_')) return sourceId;
|
||||
const sanitized = sourceId.replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
return `msg_${sanitized || Date.now()}`;
|
||||
}
|
||||
|
||||
function cleanAnthropicReasoningSignature(value: unknown): string | null {
|
||||
const raw = typeof value === 'string' ? value.trim() : '';
|
||||
if (!raw) return null;
|
||||
const decoded = decodeAnthropicReasoningSignature(raw);
|
||||
if (decoded) return decoded;
|
||||
if (raw.startsWith('metapi:')) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function buildAnthropicContent(normalized: NormalizedFinalResponse): Array<Record<string, unknown>> {
|
||||
const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse;
|
||||
if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) {
|
||||
return anthropicNormalized.nativeContent.map((block) => {
|
||||
const cloned = cloneJsonValue(block);
|
||||
const blockType = asTrimmedString(cloned.type).toLowerCase();
|
||||
if (blockType === 'thinking') {
|
||||
const signature = cleanAnthropicReasoningSignature(cloned.signature);
|
||||
if (signature) cloned.signature = signature;
|
||||
else delete cloned.signature;
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature);
|
||||
if (normalized.reasoningContent || cleanSignature) {
|
||||
const thinkingBlock: Record<string, unknown> = {
|
||||
type: 'thinking',
|
||||
thinking: normalized.reasoningContent || '',
|
||||
};
|
||||
if (cleanSignature) {
|
||||
thinkingBlock.signature = cleanSignature;
|
||||
}
|
||||
contentBlocks.push(thinkingBlock);
|
||||
}
|
||||
if (normalized.redactedReasoningContent) {
|
||||
contentBlocks.push({
|
||||
type: 'redacted_thinking',
|
||||
data: normalized.redactedReasoningContent,
|
||||
});
|
||||
}
|
||||
if (normalized.content) {
|
||||
contentBlocks.push({
|
||||
type: 'text',
|
||||
text: normalized.content,
|
||||
});
|
||||
}
|
||||
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
|
||||
for (let index = 0; index < toolCalls.length; index += 1) {
|
||||
const toolCall = toolCalls[index];
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id: toolCall.id || `toolu_${index}`,
|
||||
name: toolCall.name || `tool_${index}`,
|
||||
input: parseJsonLike(toolCall.arguments || ''),
|
||||
});
|
||||
}
|
||||
return contentBlocks.length > 0
|
||||
? contentBlocks
|
||||
: [{ type: 'text', text: '' }];
|
||||
}
|
||||
|
||||
function extractNativeAnthropicContent(payload: unknown): AnthropicRecord[] | undefined {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.content)) return undefined;
|
||||
const content = payload.content
|
||||
.filter((block): block is AnthropicRecord => isRecord(block))
|
||||
.map((block) => cloneJsonValue(block));
|
||||
return content.length > 0 ? content : undefined;
|
||||
}
|
||||
|
||||
function extractStopSequence(payload: unknown): string | null {
|
||||
if (!isRecord(payload) || typeof payload.stop_sequence !== 'string') return null;
|
||||
return payload.stop_sequence;
|
||||
}
|
||||
|
||||
function extractUsagePayload(payload: unknown): AnthropicRecord | undefined {
|
||||
if (!isRecord(payload) || !isRecord(payload.usage)) return undefined;
|
||||
return cloneJsonValue(payload.usage);
|
||||
}
|
||||
|
||||
function mergeUsagePayload(
|
||||
usage: unknown,
|
||||
normalized: AnthropicMessagesNormalizedFinalResponse,
|
||||
): AnthropicRecord {
|
||||
const merged = toAnthropicUsagePayload(usage ?? normalized.usagePayload);
|
||||
const usagePayload = normalized.usagePayload;
|
||||
if (!usagePayload) return merged;
|
||||
|
||||
for (const [key, value] of Object.entries(usagePayload)) {
|
||||
if (key === 'input_tokens' || key === 'output_tokens') continue;
|
||||
if (key === 'cache_creation' && isRecord(value) && isRecord(merged.cache_creation)) {
|
||||
merged.cache_creation = {
|
||||
...cloneJsonValue(value),
|
||||
...merged.cache_creation,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (merged[key] === undefined) {
|
||||
merged[key] = cloneJsonValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export const anthropicMessagesOutbound = {
|
||||
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): AnthropicMessagesNormalizedFinalResponse {
|
||||
const nativeContent = extractNativeAnthropicContent(payload);
|
||||
const stopSequence = extractStopSequence(payload);
|
||||
const usagePayload = extractUsagePayload(payload);
|
||||
|
||||
return {
|
||||
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
||||
...(nativeContent ? { nativeContent } : {}),
|
||||
...(stopSequence !== null ? { stopSequence } : {}),
|
||||
...(usagePayload ? { usagePayload } : {}),
|
||||
};
|
||||
},
|
||||
serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) {
|
||||
const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse;
|
||||
return {
|
||||
id: buildClaudeMessageId(normalized.id),
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: normalized.model,
|
||||
content: buildAnthropicContent(normalized),
|
||||
stop_reason: toClaudeStopReason(normalized.finishReason),
|
||||
stop_sequence: anthropicNormalized.stopSequence ?? null,
|
||||
usage: mergeUsagePayload(usage, anthropicNormalized),
|
||||
};
|
||||
},
|
||||
};
|
||||
export {
|
||||
anthropicMessagesOutbound,
|
||||
anthropicMessagesResponseBridge,
|
||||
buildNormalizedFinalToAnthropicMessagesBody,
|
||||
normalizeAnthropicMessagesFinalToNormalized,
|
||||
} from './responseBridge.js';
|
||||
export type { AnthropicMessagesNormalizedFinalResponse } from './responseBridge.js';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCanonicalRequestToAnthropicMessagesBody,
|
||||
parseAnthropicMessagesRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
describe('anthropic messages request bridge', () => {
|
||||
it('parses Anthropic messages bodies into canonical envelopes', () => {
|
||||
const result = parseAnthropicMessagesRequestToCanonical({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
user_id: 'session-claude-1',
|
||||
metapi_turn_state: 'turn-state-claude-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
surface: 'anthropic-messages',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
continuation: {
|
||||
sessionId: 'session-claude-1',
|
||||
turnState: 'turn-state-claude-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Anthropic messages bodies from canonical envelopes', () => {
|
||||
const body = buildCanonicalRequestToAnthropicMessagesBody({
|
||||
operation: 'generate',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'claude_code',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
sessionId: 'session-claude-1',
|
||||
turnState: 'turn-state-claude-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'claude-sonnet-4-5',
|
||||
metadata: {
|
||||
user_id: 'session-claude-1',
|
||||
metapi_turn_state: 'turn-state-claude-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/openAiRequestBridge.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolParseContext } from '../../contracts.js';
|
||||
import { anthropicMessagesInbound } from './inbound.js';
|
||||
import { convertOpenAiBodyToAnthropicMessagesBody } from './conversion.js';
|
||||
|
||||
export function parseAnthropicMessagesRequestToCanonical(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = anthropicMessagesInbound.parse(body);
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid messages request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: parsed.value.parsed.upstreamBody,
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCanonicalRequestToAnthropicMessagesBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
anthropicMessagesResponseBridge,
|
||||
buildNormalizedFinalToAnthropicMessagesBody,
|
||||
normalizeAnthropicMessagesFinalToNormalized,
|
||||
} from './responseBridge.js';
|
||||
import { anthropicMessagesOutbound } from './outbound.js';
|
||||
import { anthropicMessagesUsage } from './usage.js';
|
||||
|
||||
describe('anthropic messages response bridge', () => {
|
||||
it('normalizes native anthropic finals into normalized final responses', () => {
|
||||
const normalized = normalizeAnthropicMessagesFinalToNormalized({
|
||||
id: 'msg_native_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'plan first',
|
||||
signature: 'sig-native',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 30,
|
||||
},
|
||||
}, 'claude-test');
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
id: 'msg_native_1',
|
||||
model: 'claude-test',
|
||||
content: 'done',
|
||||
reasoningContent: 'plan first',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds anthropic messages payloads from normalized final responses', () => {
|
||||
const upstreamPayload = {
|
||||
id: 'msg_native_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'plan first',
|
||||
signature: 'sig-native',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const normalized = normalizeAnthropicMessagesFinalToNormalized(upstreamPayload, 'claude-test');
|
||||
const payload = buildNormalizedFinalToAnthropicMessagesBody(
|
||||
normalized,
|
||||
anthropicMessagesUsage.fromPayload(upstreamPayload),
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
id: 'msg_native_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'plan first',
|
||||
signature: 'sig-native',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves responses output-item ordering when serializing to anthropic blocks', () => {
|
||||
const normalized = normalizeAnthropicMessagesFinalToNormalized({
|
||||
id: 'resp_order_1',
|
||||
object: 'response',
|
||||
model: 'gpt-5',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
id: 'rs_reasoning_1',
|
||||
type: 'reasoning',
|
||||
encrypted_content: 'metapi:anthropic-signature:sig-resp-1',
|
||||
summary: [
|
||||
{ type: 'summary_text', text: 'plan first' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fc_weather_1',
|
||||
type: 'function_call',
|
||||
call_id: 'call_weather_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Paris"}',
|
||||
},
|
||||
{
|
||||
id: 'msg_order_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{ type: 'output_text', text: 'done' },
|
||||
],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
output_tokens: 6,
|
||||
},
|
||||
}, 'gpt-5');
|
||||
|
||||
const payload = buildNormalizedFinalToAnthropicMessagesBody(normalized, {
|
||||
promptTokens: 12,
|
||||
completionTokens: 6,
|
||||
totalTokens: 18,
|
||||
});
|
||||
|
||||
expect(payload.content).toEqual([
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'plan first',
|
||||
signature: 'sig-resp-1',
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call_weather_1',
|
||||
name: 'lookup_weather',
|
||||
input: { city: 'Paris' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps responses web_search_call items into anthropic server tool blocks', () => {
|
||||
const normalized = normalizeAnthropicMessagesFinalToNormalized({
|
||||
id: 'resp_search_1',
|
||||
object: 'response',
|
||||
model: 'gpt-5',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
id: 'ws_1',
|
||||
type: 'web_search_call',
|
||||
status: 'completed',
|
||||
action: {
|
||||
query: 'weather in Paris',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'msg_search_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{ type: 'output_text', text: 'It is sunny.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 9,
|
||||
output_tokens: 4,
|
||||
},
|
||||
}, 'gpt-5');
|
||||
|
||||
const payload = buildNormalizedFinalToAnthropicMessagesBody(normalized, {
|
||||
promptTokens: 9,
|
||||
completionTokens: 4,
|
||||
totalTokens: 13,
|
||||
});
|
||||
|
||||
expect(payload.content).toEqual([
|
||||
{
|
||||
type: 'server_tool_use',
|
||||
id: 'srvtoolu_ws_1',
|
||||
name: 'web_search',
|
||||
input: {
|
||||
query: 'weather in Paris',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'web_search_tool_result',
|
||||
tool_use_id: 'srvtoolu_ws_1',
|
||||
content: [],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'It is sunny.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the outbound facade pointed at the response bridge object', () => {
|
||||
expect(anthropicMessagesOutbound).toBe(anthropicMessagesResponseBridge);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js';
|
||||
import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js';
|
||||
import { toAnthropicUsagePayload } from './usage.js';
|
||||
|
||||
type AnthropicRecord = Record<string, unknown>;
|
||||
|
||||
export type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & {
|
||||
nativeContent?: AnthropicRecord[];
|
||||
stopSequence?: string | null;
|
||||
usagePayload?: AnthropicRecord;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is AnthropicRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseJsonLike(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { value: raw };
|
||||
}
|
||||
}
|
||||
|
||||
function buildClaudeMessageId(sourceId: string): string {
|
||||
if (sourceId.startsWith('msg_')) return sourceId;
|
||||
const sanitized = sourceId.replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
return `msg_${sanitized || Date.now()}`;
|
||||
}
|
||||
|
||||
function cleanAnthropicReasoningSignature(value: unknown): string | null {
|
||||
const raw = typeof value === 'string' ? value.trim() : '';
|
||||
if (!raw) return null;
|
||||
const decoded = decodeAnthropicReasoningSignature(raw);
|
||||
if (decoded) return decoded;
|
||||
if (raw.startsWith('metapi:')) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function buildAnthropicContent(normalized: NormalizedFinalResponse): Array<Record<string, unknown>> {
|
||||
const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse;
|
||||
if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) {
|
||||
return anthropicNormalized.nativeContent.map((block) => {
|
||||
const cloned = cloneJsonValue(block);
|
||||
const blockType = asTrimmedString(cloned.type).toLowerCase();
|
||||
if (blockType === 'thinking') {
|
||||
const signature = cleanAnthropicReasoningSignature(cloned.signature);
|
||||
if (signature) cloned.signature = signature;
|
||||
else delete cloned.signature;
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature);
|
||||
if (normalized.reasoningContent || cleanSignature) {
|
||||
const thinkingBlock: Record<string, unknown> = {
|
||||
type: 'thinking',
|
||||
thinking: normalized.reasoningContent || '',
|
||||
};
|
||||
if (cleanSignature) {
|
||||
thinkingBlock.signature = cleanSignature;
|
||||
}
|
||||
contentBlocks.push(thinkingBlock);
|
||||
}
|
||||
if (normalized.redactedReasoningContent) {
|
||||
contentBlocks.push({
|
||||
type: 'redacted_thinking',
|
||||
data: normalized.redactedReasoningContent,
|
||||
});
|
||||
}
|
||||
if (normalized.content) {
|
||||
contentBlocks.push({
|
||||
type: 'text',
|
||||
text: normalized.content,
|
||||
});
|
||||
}
|
||||
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
|
||||
for (let index = 0; index < toolCalls.length; index += 1) {
|
||||
const toolCall = toolCalls[index];
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id: toolCall.id || `toolu_${index}`,
|
||||
name: toolCall.name || `tool_${index}`,
|
||||
input: parseJsonLike(toolCall.arguments || ''),
|
||||
});
|
||||
}
|
||||
return contentBlocks.length > 0
|
||||
? contentBlocks
|
||||
: [{ type: 'text', text: '' }];
|
||||
}
|
||||
|
||||
function extractNativeAnthropicContent(payload: unknown): AnthropicRecord[] | undefined {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.content)) return undefined;
|
||||
const content = payload.content
|
||||
.filter((block): block is AnthropicRecord => isRecord(block))
|
||||
.map((block) => cloneJsonValue(block));
|
||||
return content.length > 0 ? content : undefined;
|
||||
}
|
||||
|
||||
function extractAnthropicContentFromResponsesPayload(payload: unknown): AnthropicRecord[] | undefined {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.output)) return undefined;
|
||||
|
||||
const contentBlocks: AnthropicRecord[] = [];
|
||||
|
||||
for (const outputItem of payload.output) {
|
||||
if (!isRecord(outputItem)) continue;
|
||||
const itemType = asTrimmedString(outputItem.type).toLowerCase();
|
||||
|
||||
if (itemType === 'reasoning') {
|
||||
const summary = Array.isArray(outputItem.summary) ? outputItem.summary : [];
|
||||
const thinking = summary
|
||||
.filter((part): part is AnthropicRecord => isRecord(part))
|
||||
.map((part) => asTrimmedString(part.text))
|
||||
.filter((text) => text.length > 0)
|
||||
.join('');
|
||||
const block: AnthropicRecord = {
|
||||
type: 'thinking',
|
||||
thinking,
|
||||
};
|
||||
const signature = cleanAnthropicReasoningSignature(outputItem.encrypted_content);
|
||||
if (signature) {
|
||||
block.signature = signature;
|
||||
}
|
||||
contentBlocks.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (itemType === 'message') {
|
||||
const parts = Array.isArray(outputItem.content) ? outputItem.content : [];
|
||||
for (const part of parts) {
|
||||
if (!isRecord(part)) continue;
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
if ((partType === 'output_text' || partType === 'text') && typeof part.text === 'string') {
|
||||
contentBlocks.push({
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (itemType === 'function_call') {
|
||||
const toolUseId = asTrimmedString(outputItem.call_id) || asTrimmedString(outputItem.id) || `toolu_${contentBlocks.length}`;
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id: toolUseId,
|
||||
name: asTrimmedString(outputItem.name) || 'tool',
|
||||
input: parseJsonLike(typeof outputItem.arguments === 'string' ? outputItem.arguments : ''),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (itemType === 'web_search_call') {
|
||||
const sourceId = asTrimmedString(outputItem.id) || `${contentBlocks.length}`;
|
||||
const toolUseId = `srvtoolu_${sourceId}`;
|
||||
const action = isRecord(outputItem.action) ? outputItem.action : null;
|
||||
const query = asTrimmedString(action?.query);
|
||||
|
||||
contentBlocks.push({
|
||||
type: 'server_tool_use',
|
||||
id: toolUseId,
|
||||
name: 'web_search',
|
||||
input: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
contentBlocks.push({
|
||||
type: 'web_search_tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return contentBlocks.length > 0 ? contentBlocks : undefined;
|
||||
}
|
||||
|
||||
function extractStopSequence(payload: unknown): string | null {
|
||||
if (!isRecord(payload) || typeof payload.stop_sequence !== 'string') return null;
|
||||
return payload.stop_sequence;
|
||||
}
|
||||
|
||||
function extractUsagePayload(payload: unknown): AnthropicRecord | undefined {
|
||||
if (!isRecord(payload) || !isRecord(payload.usage)) return undefined;
|
||||
return cloneJsonValue(payload.usage);
|
||||
}
|
||||
|
||||
function mergeUsagePayload(
|
||||
usage: unknown,
|
||||
normalized: AnthropicMessagesNormalizedFinalResponse,
|
||||
): AnthropicRecord {
|
||||
const merged = toAnthropicUsagePayload(usage ?? normalized.usagePayload);
|
||||
const usagePayload = normalized.usagePayload;
|
||||
if (!usagePayload) return merged;
|
||||
|
||||
for (const [key, value] of Object.entries(usagePayload)) {
|
||||
if (key === 'input_tokens' || key === 'output_tokens') continue;
|
||||
if (key === 'cache_creation' && isRecord(value) && isRecord(merged.cache_creation)) {
|
||||
merged.cache_creation = {
|
||||
...cloneJsonValue(value),
|
||||
...merged.cache_creation,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (merged[key] === undefined) {
|
||||
merged[key] = cloneJsonValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function normalizeAnthropicMessagesFinalToNormalized(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText = '',
|
||||
): AnthropicMessagesNormalizedFinalResponse {
|
||||
const nativeContent = extractNativeAnthropicContent(payload) ?? extractAnthropicContentFromResponsesPayload(payload);
|
||||
const stopSequence = extractStopSequence(payload);
|
||||
const usagePayload = extractUsagePayload(payload);
|
||||
|
||||
return {
|
||||
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
||||
...(nativeContent ? { nativeContent } : {}),
|
||||
...(stopSequence !== null ? { stopSequence } : {}),
|
||||
...(usagePayload ? { usagePayload } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNormalizedFinalToAnthropicMessagesBody(
|
||||
normalized: NormalizedFinalResponse,
|
||||
usage?: unknown,
|
||||
) {
|
||||
const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse;
|
||||
return {
|
||||
id: buildClaudeMessageId(normalized.id),
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: normalized.model,
|
||||
content: buildAnthropicContent(normalized),
|
||||
stop_reason: toClaudeStopReason(normalized.finishReason),
|
||||
stop_sequence: anthropicNormalized.stopSequence ?? null,
|
||||
usage: mergeUsagePayload(usage, anthropicNormalized),
|
||||
};
|
||||
}
|
||||
|
||||
export const anthropicMessagesResponseBridge = {
|
||||
normalizeFinal: normalizeAnthropicMessagesFinalToNormalized,
|
||||
serializeFinal: buildNormalizedFinalToAnthropicMessagesBody,
|
||||
};
|
||||
|
||||
export const anthropicMessagesOutbound = anthropicMessagesResponseBridge;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
anthropicMessagesStream,
|
||||
consumeAnthropicSseEvent,
|
||||
serializeAnthropicUpstreamFinalAsStream,
|
||||
} from './streamBridge.js';
|
||||
import { normalizeAnthropicMessagesFinalToNormalized } from './responseBridge.js';
|
||||
|
||||
describe('anthropic messages stream bridge', () => {
|
||||
it('passes through recognized raw anthropic SSE events and marks completion on message_stop', () => {
|
||||
const streamContext = anthropicMessagesStream.createContext('claude-test');
|
||||
const downstreamContext = anthropicMessagesStream.createDownstreamContext();
|
||||
|
||||
const result = consumeAnthropicSseEvent(
|
||||
{
|
||||
event: 'message_stop',
|
||||
data: JSON.stringify({ type: 'message_stop' }),
|
||||
},
|
||||
streamContext,
|
||||
downstreamContext,
|
||||
'claude-test',
|
||||
);
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.lines.join('')).toContain('event: message_stop');
|
||||
});
|
||||
|
||||
it('serializes normalized upstream finals back into anthropic SSE blocks', () => {
|
||||
const streamContext = anthropicMessagesStream.createContext('claude-test');
|
||||
const downstreamContext = anthropicMessagesStream.createDownstreamContext();
|
||||
|
||||
const lines = serializeAnthropicUpstreamFinalAsStream(
|
||||
{
|
||||
id: 'msg_native_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'plan first',
|
||||
signature: 'sig-native',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 30,
|
||||
},
|
||||
},
|
||||
'claude-test',
|
||||
'',
|
||||
normalizeAnthropicMessagesFinalToNormalized,
|
||||
streamContext,
|
||||
downstreamContext,
|
||||
);
|
||||
|
||||
const parsed = anthropicMessagesStream.pullSseEvents(lines.join(''));
|
||||
expect(parsed.events.some((event) => event.event === 'message_start')).toBe(true);
|
||||
expect(parsed.events.some((event) => event.event === 'message_stop')).toBe(true);
|
||||
expect(lines.join('')).toContain('sig-native');
|
||||
});
|
||||
|
||||
it('preserves responses output-item ordering when streaming anthropic fallback blocks', () => {
|
||||
const streamContext = anthropicMessagesStream.createContext('claude-test');
|
||||
const downstreamContext = anthropicMessagesStream.createDownstreamContext();
|
||||
|
||||
const lines = serializeAnthropicUpstreamFinalAsStream(
|
||||
{
|
||||
id: 'resp_stream_order_1',
|
||||
object: 'response',
|
||||
model: 'gpt-5',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
id: 'rs_reasoning_stream_1',
|
||||
type: 'reasoning',
|
||||
encrypted_content: 'metapi:anthropic-signature:sig-stream-1',
|
||||
summary: [{ type: 'summary_text', text: 'plan first' }],
|
||||
},
|
||||
{
|
||||
id: 'fc_stream_1',
|
||||
type: 'function_call',
|
||||
call_id: 'call_stream_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Paris"}',
|
||||
},
|
||||
{
|
||||
id: 'msg_stream_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'done' }],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 4,
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
'',
|
||||
normalizeAnthropicMessagesFinalToNormalized,
|
||||
streamContext,
|
||||
downstreamContext,
|
||||
);
|
||||
|
||||
const serialized = lines.join('');
|
||||
expect(serialized.indexOf('"type":"thinking"')).toBeGreaterThan(-1);
|
||||
expect(serialized.indexOf('"type":"tool_use"')).toBeGreaterThan(serialized.indexOf('"type":"thinking"'));
|
||||
expect(serialized.indexOf('"text":"done"')).toBeGreaterThan(serialized.indexOf('"type":"tool_use"'));
|
||||
expect(serialized).toContain('"signature":"sig-stream-1"');
|
||||
});
|
||||
|
||||
it('serializes responses web_search_call items into anthropic server tool stream blocks', () => {
|
||||
const streamContext = anthropicMessagesStream.createContext('claude-test');
|
||||
const downstreamContext = anthropicMessagesStream.createDownstreamContext();
|
||||
|
||||
const lines = serializeAnthropicUpstreamFinalAsStream(
|
||||
{
|
||||
id: 'resp_stream_search_1',
|
||||
object: 'response',
|
||||
model: 'gpt-5',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
id: 'ws_stream_1',
|
||||
type: 'web_search_call',
|
||||
status: 'completed',
|
||||
action: {
|
||||
query: 'weather in Paris',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'msg_stream_search_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'It is sunny.' }],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 8,
|
||||
output_tokens: 3,
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
'',
|
||||
normalizeAnthropicMessagesFinalToNormalized,
|
||||
streamContext,
|
||||
downstreamContext,
|
||||
);
|
||||
|
||||
const serialized = lines.join('');
|
||||
expect(serialized).toContain('"type":"server_tool_use"');
|
||||
expect(serialized).toContain('"name":"web_search"');
|
||||
expect(serialized).toContain('"type":"web_search_tool_result"');
|
||||
expect(serialized).toContain('"tool_use_id":"srvtoolu_ws_stream_1"');
|
||||
expect(serialized.indexOf('"type":"web_search_tool_result"')).toBeGreaterThan(serialized.indexOf('"type":"server_tool_use"'));
|
||||
expect(serialized).toContain('"text":"It is sunny."');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyOpenAiCompatibleContinuation,
|
||||
buildOpenAiCompatibleMetadataWithContinuation,
|
||||
normalizeCanonicalContinuation,
|
||||
OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY,
|
||||
readOpenAiCompatibleContinuation,
|
||||
} from './continuationBridge.js';
|
||||
|
||||
describe('canonical continuation bridge helpers', () => {
|
||||
it('normalizes canonical continuation fields and drops blanks', () => {
|
||||
expect(normalizeCanonicalContinuation({
|
||||
sessionId: ' session-1 ',
|
||||
previousResponseId: ' ',
|
||||
promptCacheKey: ' cache-1 ',
|
||||
turnState: ' turn-1 ',
|
||||
})).toEqual({
|
||||
sessionId: 'session-1',
|
||||
promptCacheKey: 'cache-1',
|
||||
turnState: 'turn-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads openai-compatible continuation fields from top-level fields and metadata namespace', () => {
|
||||
expect(readOpenAiCompatibleContinuation({
|
||||
session_id: 'session-top-level',
|
||||
conversation_id: 'conversation-top-level',
|
||||
previous_response_id: 'resp-1',
|
||||
prompt_cache_key: 'cache-1',
|
||||
metadata: {
|
||||
user_id: 'session-metadata',
|
||||
[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]: 'turn-state-1',
|
||||
},
|
||||
})).toEqual({
|
||||
sessionId: 'session-metadata',
|
||||
previousResponseId: 'resp-1',
|
||||
promptCacheKey: 'cache-1',
|
||||
turnState: 'turn-state-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('materializes continuation semantics into openai-compatible metadata and body fields', () => {
|
||||
const body: Record<string, unknown> = {
|
||||
model: 'gpt-5',
|
||||
};
|
||||
|
||||
applyOpenAiCompatibleContinuation(body, {
|
||||
sessionId: 'session-bridge-1',
|
||||
previousResponseId: 'resp-bridge-1',
|
||||
promptCacheKey: 'cache-bridge-1',
|
||||
turnState: 'turn-bridge-1',
|
||||
}, { existing: true });
|
||||
|
||||
expect(body).toEqual({
|
||||
model: 'gpt-5',
|
||||
previous_response_id: 'resp-bridge-1',
|
||||
prompt_cache_key: 'cache-bridge-1',
|
||||
metadata: {
|
||||
existing: true,
|
||||
user_id: 'session-bridge-1',
|
||||
[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]: 'turn-bridge-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overwrite explicit metadata when materializing continuation semantics', () => {
|
||||
expect(buildOpenAiCompatibleMetadataWithContinuation({
|
||||
user_id: 'user-explicit',
|
||||
[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]: 'turn-explicit',
|
||||
}, {
|
||||
sessionId: 'session-bridge-1',
|
||||
turnState: 'turn-bridge-1',
|
||||
})).toEqual({
|
||||
user_id: 'user-explicit',
|
||||
[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]: 'turn-explicit',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { CanonicalContinuation } from './types.js';
|
||||
|
||||
export const OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY = 'metapi_turn_state';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeCanonicalContinuation(
|
||||
continuation: CanonicalContinuation | undefined,
|
||||
): CanonicalContinuation | undefined {
|
||||
if (!continuation) return undefined;
|
||||
|
||||
const normalized: CanonicalContinuation = {
|
||||
...(asTrimmedString(continuation.sessionId) ? { sessionId: asTrimmedString(continuation.sessionId) } : {}),
|
||||
...(asTrimmedString(continuation.previousResponseId)
|
||||
? { previousResponseId: asTrimmedString(continuation.previousResponseId) }
|
||||
: {}),
|
||||
...(asTrimmedString(continuation.promptCacheKey)
|
||||
? { promptCacheKey: asTrimmedString(continuation.promptCacheKey) }
|
||||
: {}),
|
||||
...(asTrimmedString(continuation.turnState) ? { turnState: asTrimmedString(continuation.turnState) } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function readOpenAiCompatibleContinuation(
|
||||
body: Record<string, unknown>,
|
||||
existing?: CanonicalContinuation,
|
||||
): CanonicalContinuation | undefined {
|
||||
const metadata = isRecord(body.metadata) ? body.metadata : null;
|
||||
return normalizeCanonicalContinuation({
|
||||
...(existing ?? {}),
|
||||
...(asTrimmedString(body.session_id)
|
||||
? { sessionId: asTrimmedString(body.session_id) }
|
||||
: {}),
|
||||
...(asTrimmedString(body.conversation_id)
|
||||
? { sessionId: asTrimmedString(body.conversation_id) }
|
||||
: {}),
|
||||
...(metadata && asTrimmedString(metadata.user_id)
|
||||
? { sessionId: asTrimmedString(metadata.user_id) }
|
||||
: {}),
|
||||
...(asTrimmedString(body.previous_response_id)
|
||||
? { previousResponseId: asTrimmedString(body.previous_response_id) }
|
||||
: {}),
|
||||
...(asTrimmedString(body.prompt_cache_key)
|
||||
? { promptCacheKey: asTrimmedString(body.prompt_cache_key) }
|
||||
: {}),
|
||||
...(metadata && asTrimmedString(metadata[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY])
|
||||
? { turnState: asTrimmedString(metadata[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY]) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOpenAiCompatibleMetadataWithContinuation(
|
||||
metadata: Record<string, unknown>,
|
||||
continuation: CanonicalContinuation | undefined,
|
||||
): Record<string, unknown> {
|
||||
const normalized = normalizeCanonicalContinuation(continuation);
|
||||
const nextMetadata: Record<string, unknown> = { ...metadata };
|
||||
|
||||
if (!normalized) return nextMetadata;
|
||||
if (!('user_id' in nextMetadata) && normalized.sessionId) {
|
||||
nextMetadata.user_id = normalized.sessionId;
|
||||
}
|
||||
if (!(OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY in nextMetadata) && normalized.turnState) {
|
||||
nextMetadata[OPENAI_CONTINUATION_TURN_STATE_METADATA_KEY] = normalized.turnState;
|
||||
}
|
||||
return nextMetadata;
|
||||
}
|
||||
|
||||
export function applyOpenAiCompatibleContinuation(
|
||||
body: Record<string, unknown>,
|
||||
continuation: CanonicalContinuation | undefined,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): void {
|
||||
const normalized = normalizeCanonicalContinuation(continuation);
|
||||
const nextMetadata = buildOpenAiCompatibleMetadataWithContinuation(metadata, normalized);
|
||||
|
||||
if (Object.keys(nextMetadata).length > 0) {
|
||||
body.metadata = nextMetadata;
|
||||
}
|
||||
if (normalized?.promptCacheKey) {
|
||||
body.prompt_cache_key = normalized.promptCacheKey;
|
||||
}
|
||||
if (normalized?.previousResponseId) {
|
||||
body.previous_response_id = normalized.previousResponseId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
|
||||
import type { CanonicalAttachment } from './attachments.js';
|
||||
import type {
|
||||
CanonicalCliProfile,
|
||||
CanonicalContinuation,
|
||||
CanonicalMessage,
|
||||
CanonicalOperation,
|
||||
CanonicalReasoningRequest,
|
||||
CanonicalRequestEnvelope,
|
||||
CanonicalSurface,
|
||||
} from './types.js';
|
||||
import { normalizeCanonicalContinuation } from './continuationBridge.js';
|
||||
|
||||
export type CreateCanonicalRequestEnvelopeInput = {
|
||||
operation?: CanonicalOperation;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
requestedModel: string;
|
||||
stream?: boolean;
|
||||
messages?: CanonicalMessage[];
|
||||
reasoning?: CanonicalReasoningRequest;
|
||||
tools?: CanonicalTool[];
|
||||
toolChoice?: CanonicalToolChoice;
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
attachments?: CanonicalAttachment[];
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createCanonicalRequestEnvelope(
|
||||
input: CreateCanonicalRequestEnvelopeInput,
|
||||
): CanonicalRequestEnvelope {
|
||||
const requestedModel = asTrimmedString(input.requestedModel);
|
||||
if (!requestedModel) {
|
||||
throw new Error('canonical request requires requestedModel');
|
||||
}
|
||||
|
||||
return {
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
cliProfile: input.cliProfile ?? 'generic',
|
||||
requestedModel,
|
||||
stream: input.stream === true,
|
||||
messages: Array.isArray(input.messages) ? input.messages : [],
|
||||
...(input.reasoning ? { reasoning: input.reasoning } : {}),
|
||||
...(Array.isArray(input.tools) && input.tools.length > 0 ? { tools: input.tools } : {}),
|
||||
...(input.toolChoice !== undefined ? { toolChoice: input.toolChoice } : {}),
|
||||
...(normalizeCanonicalContinuation(input.continuation)
|
||||
? { continuation: normalizeCanonicalContinuation(input.continuation) }
|
||||
: {}),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
...(input.passthrough ? { passthrough: input.passthrough } : {}),
|
||||
...(Array.isArray(input.attachments) && input.attachments.length > 0
|
||||
? { attachments: cloneJsonValue(input.attachments) as CanonicalAttachment[] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canonicalRequestFromOpenAiBody,
|
||||
canonicalRequestToOpenAiChatBody,
|
||||
} from './openAiRequestBridge.js';
|
||||
|
||||
describe('openAiRequestBridge', () => {
|
||||
it('parses openai-compatible continuation hints into canonical envelopes', () => {
|
||||
const request = canonicalRequestFromOpenAiBody({
|
||||
body: {
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
conversation_id: 'conversation-1',
|
||||
previous_response_id: 'resp-1',
|
||||
prompt_cache_key: 'cache-1',
|
||||
metadata: {
|
||||
user_id: 'session-1',
|
||||
metapi_turn_state: 'turn-state-1',
|
||||
},
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
surface: 'openai-responses',
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
continuation: {
|
||||
sessionId: 'session-1',
|
||||
previousResponseId: 'resp-1',
|
||||
promptCacheKey: 'cache-1',
|
||||
turnState: 'turn-state-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds openai-compatible continuation fields back from canonical envelopes', () => {
|
||||
const body = canonicalRequestToOpenAiChatBody({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
sessionId: 'session-1',
|
||||
previousResponseId: 'resp-1',
|
||||
promptCacheKey: 'cache-1',
|
||||
turnState: 'turn-state-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
previous_response_id: 'resp-1',
|
||||
prompt_cache_key: 'cache-1',
|
||||
metadata: {
|
||||
user_id: 'session-1',
|
||||
metapi_turn_state: 'turn-state-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,564 @@
|
||||
import {
|
||||
canonicalAttachmentFromInputFileBlock,
|
||||
canonicalAttachmentToNormalizedInputFile,
|
||||
type CanonicalAttachment,
|
||||
} from './attachments.js';
|
||||
import { createCanonicalRequestEnvelope } from './envelope.js';
|
||||
import {
|
||||
applyOpenAiCompatibleContinuation,
|
||||
readOpenAiCompatibleContinuation,
|
||||
} from './continuationBridge.js';
|
||||
import { normalizeCanonicalReasoningRequest } from './reasoning.js';
|
||||
import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
|
||||
import type {
|
||||
CanonicalContentPart,
|
||||
CanonicalCliProfile,
|
||||
CanonicalContinuation,
|
||||
CanonicalMessage,
|
||||
CanonicalMessageRole,
|
||||
CanonicalOperation,
|
||||
CanonicalRequestEnvelope,
|
||||
CanonicalSurface,
|
||||
} from './types.js';
|
||||
import { toOpenAiChatFileBlock } from '../shared/inputFile.js';
|
||||
|
||||
type CanonicalRequestFromOpenAiBodyInput = {
|
||||
body: Record<string, unknown>;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
operation?: CanonicalOperation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
continuation?: CanonicalContinuation;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function joinNonEmpty(parts: string[]): string {
|
||||
return parts
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): CanonicalMessageRole {
|
||||
const role = asTrimmedString(value).toLowerCase();
|
||||
switch (role) {
|
||||
case 'system':
|
||||
case 'developer':
|
||||
case 'assistant':
|
||||
case 'tool':
|
||||
return role;
|
||||
default:
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
||||
function openAiContentToCanonicalParts(content: unknown): CanonicalContentPart[] {
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const parts: CanonicalContentPart[] = [];
|
||||
for (const item of content) {
|
||||
if (typeof item === 'string') {
|
||||
if (item) parts.push({ type: 'text', text: item });
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
||||
const text = asTrimmedString(item.text);
|
||||
if (text) parts.push({ type: 'text', text });
|
||||
continue;
|
||||
}
|
||||
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
||||
const text = asTrimmedString(item.text ?? item.reasoning ?? item.thinking);
|
||||
if (text) parts.push({ type: 'text', text, thought: true });
|
||||
continue;
|
||||
}
|
||||
if (type === 'image_url' && isRecord(item.image_url)) {
|
||||
const url = asTrimmedString(item.image_url.url);
|
||||
if (url) parts.push({ type: 'image', url });
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_image' && isRecord(item.image_url)) {
|
||||
const url = asTrimmedString(item.image_url.url);
|
||||
if (url) parts.push({ type: 'image', url });
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
const attachment = canonicalAttachmentFromInputFileBlock(item);
|
||||
if (attachment) {
|
||||
parts.push({
|
||||
type: 'file',
|
||||
...(attachment.fileId ? { fileId: attachment.fileId } : {}),
|
||||
...(attachment.fileUrl ? { fileUrl: attachment.fileUrl } : {}),
|
||||
...(attachment.fileData ? { fileData: attachment.fileData } : {}),
|
||||
...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}),
|
||||
...(attachment.filename ? { filename: attachment.filename } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function appendAssistantReasoningPart(
|
||||
parts: CanonicalContentPart[],
|
||||
rawMessage: Record<string, unknown>,
|
||||
): void {
|
||||
const directReasoning = joinNonEmpty([
|
||||
asTrimmedString(rawMessage.reasoning_content),
|
||||
asTrimmedString(rawMessage.reasoning),
|
||||
]);
|
||||
if (!directReasoning) return;
|
||||
|
||||
const alreadyPresent = parts.some((part) => (
|
||||
part.type === 'text'
|
||||
&& part.thought === true
|
||||
&& part.text === directReasoning
|
||||
));
|
||||
if (alreadyPresent) return;
|
||||
|
||||
parts.unshift({
|
||||
type: 'text',
|
||||
text: directReasoning,
|
||||
thought: true,
|
||||
});
|
||||
}
|
||||
|
||||
function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefined {
|
||||
if (typeof rawToolChoice === 'string') {
|
||||
const normalized = rawToolChoice.trim().toLowerCase();
|
||||
if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized;
|
||||
if (normalized === 'any') return 'required';
|
||||
return rawToolChoice.trim() ? { type: 'raw', value: rawToolChoice } : undefined;
|
||||
}
|
||||
|
||||
if (!isRecord(rawToolChoice)) return undefined;
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'auto' || type === 'none') return type;
|
||||
if (type === 'any' || type === 'required') return 'required';
|
||||
if (type === 'function') {
|
||||
const name = asTrimmedString(
|
||||
(isRecord(rawToolChoice.function) ? rawToolChoice.function.name : undefined)
|
||||
?? rawToolChoice.name,
|
||||
);
|
||||
return name ? { type: 'tool', name } : undefined;
|
||||
}
|
||||
if (type && type !== 'tool') {
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
const name = asTrimmedString(
|
||||
rawToolChoice.name
|
||||
?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined),
|
||||
);
|
||||
const toolChoiceKeys = Object.keys(rawToolChoice);
|
||||
const hasExtraToolFields = toolChoiceKeys.some((key) => key !== 'type' && key !== 'name' && key !== 'tool');
|
||||
if (hasExtraToolFields) {
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
if (name) return { type: 'tool', name };
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
|
||||
if (!Array.isArray(rawTools)) return undefined;
|
||||
|
||||
const tools: CanonicalTool[] = rawTools
|
||||
.flatMap((item): CanonicalTool[] => {
|
||||
if (!isRecord(item)) return [];
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (itemType === 'function' && isRecord(item.function)) {
|
||||
const name = asTrimmedString(item.function.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(item.function.description)
|
||||
? { description: asTrimmedString(item.function.description) }
|
||||
: {}),
|
||||
...(typeof item.function.strict === 'boolean' ? { strict: item.function.strict } : {}),
|
||||
...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}),
|
||||
}];
|
||||
}
|
||||
|
||||
if ((itemType === '' || itemType === 'tool') && asTrimmedString(item.name)) {
|
||||
return [{
|
||||
name: asTrimmedString(item.name),
|
||||
...(asTrimmedString(item.description)
|
||||
? { description: asTrimmedString(item.description) }
|
||||
: {}),
|
||||
...(typeof item.strict === 'boolean' ? { strict: item.strict } : {}),
|
||||
...(isRecord(item.input_schema)
|
||||
? { inputSchema: cloneJsonValue(item.input_schema) }
|
||||
: (isRecord(item.inputSchema) ? { inputSchema: cloneJsonValue(item.inputSchema) } : {})),
|
||||
}];
|
||||
}
|
||||
|
||||
if (Array.isArray(item.functionDeclarations)) {
|
||||
return item.functionDeclarations.flatMap((declaration) => {
|
||||
if (!isRecord(declaration)) return [];
|
||||
const name = asTrimmedString(declaration.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(declaration.description)
|
||||
? { description: asTrimmedString(declaration.description) }
|
||||
: {}),
|
||||
...(isRecord(declaration.parametersJsonSchema)
|
||||
? { inputSchema: cloneJsonValue(declaration.parametersJsonSchema) }
|
||||
: (isRecord(declaration.parameters) ? { inputSchema: cloneJsonValue(declaration.parameters) } : {})),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (itemType) {
|
||||
return [{
|
||||
type: itemType,
|
||||
raw: cloneJsonValue(item) as Record<string, unknown>,
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
export function canonicalRequestFromOpenAiBody(
|
||||
input: CanonicalRequestFromOpenAiBodyInput,
|
||||
): CanonicalRequestEnvelope {
|
||||
const body = input.body;
|
||||
const metadata = isRecord(input.metadata)
|
||||
? input.metadata
|
||||
: (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined);
|
||||
const attachments = Array.isArray(body.attachments)
|
||||
? cloneJsonValue(body.attachments) as CanonicalAttachment[]
|
||||
: undefined;
|
||||
const messages: CanonicalMessage[] = [];
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
|
||||
for (const rawMessage of rawMessages) {
|
||||
if (!isRecord(rawMessage)) continue;
|
||||
const role = normalizeRole(rawMessage.role);
|
||||
|
||||
if (role === 'tool') {
|
||||
const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id);
|
||||
const rawContent = rawMessage.content;
|
||||
const resultText = typeof rawContent === 'string'
|
||||
? rawContent
|
||||
: (!Array.isArray(rawContent) && !isRecord(rawContent) ? safeJsonStringify(rawContent ?? '') : '');
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: toolCallId || 'tool',
|
||||
...(resultText ? { resultText } : {}),
|
||||
...(Array.isArray(rawContent)
|
||||
? { resultContent: cloneJsonValue(rawContent) as Array<string | Record<string, unknown>> }
|
||||
: (isRecord(rawContent)
|
||||
? { resultContent: [cloneJsonValue(rawContent) as Record<string, unknown>] }
|
||||
: {})),
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = openAiContentToCanonicalParts(rawMessage.content);
|
||||
if (role === 'assistant') {
|
||||
appendAssistantReasoningPart(parts, rawMessage);
|
||||
}
|
||||
const toolCalls = Array.isArray(rawMessage.tool_calls) ? rawMessage.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const fn = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const name = asTrimmedString(toolCall.name ?? fn.name);
|
||||
const argumentsJson = typeof fn.arguments === 'string'
|
||||
? fn.arguments
|
||||
: safeJsonStringify(fn.arguments ?? toolCall.arguments ?? {});
|
||||
if (!name) continue;
|
||||
parts.push({
|
||||
type: 'tool_call',
|
||||
id: id || `tool_${parts.length}`,
|
||||
name,
|
||||
argumentsJson,
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role,
|
||||
parts,
|
||||
...(asTrimmedString(rawMessage.phase) ? { phase: asTrimmedString(rawMessage.phase) } : {}),
|
||||
...(asTrimmedString(rawMessage.reasoning_signature)
|
||||
? { reasoningSignature: asTrimmedString(rawMessage.reasoning_signature) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
const reasoningResult = normalizeCanonicalReasoningRequest({
|
||||
include: body.include,
|
||||
reasoning: body.reasoning,
|
||||
reasoning_effort: body.reasoning_effort,
|
||||
reasoning_budget: body.reasoning_budget,
|
||||
reasoning_summary: body.reasoning_summary,
|
||||
});
|
||||
|
||||
const continuation = readOpenAiCompatibleContinuation(body, input.continuation);
|
||||
|
||||
const passthrough = {
|
||||
...(input.passthrough ?? {}),
|
||||
...(typeof body.parallel_tool_calls === 'boolean'
|
||||
? { parallel_tool_calls: body.parallel_tool_calls }
|
||||
: {}),
|
||||
...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}),
|
||||
};
|
||||
|
||||
return createCanonicalRequestEnvelope({
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
cliProfile: input.cliProfile ?? 'generic',
|
||||
requestedModel: asTrimmedString(body.model),
|
||||
stream: body.stream === true,
|
||||
messages,
|
||||
...(reasoningResult.reasoning ? { reasoning: reasoningResult.reasoning } : {}),
|
||||
...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}),
|
||||
...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}),
|
||||
...(continuation ? { continuation } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
...(attachments ? { attachments } : {}),
|
||||
...(Object.keys(passthrough).length > 0 ? { passthrough } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalPartsToOpenAiContent(
|
||||
role: CanonicalMessageRole,
|
||||
parts: CanonicalContentPart[],
|
||||
): { content: string | Array<Record<string, unknown>>; reasoning?: string; toolCalls?: Array<Record<string, unknown>> } {
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const toolCalls: Array<Record<string, unknown>> = [];
|
||||
const visibleText: string[] = [];
|
||||
const reasoningText: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
if (part.thought === true) {
|
||||
reasoningText.push(part.text);
|
||||
} else {
|
||||
visibleText.push(part.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'image') {
|
||||
const url = asTrimmedString(part.url ?? part.dataUrl);
|
||||
if (url) {
|
||||
contentBlocks.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'file') {
|
||||
const normalizedFile = canonicalAttachmentToNormalizedInputFile({
|
||||
kind: 'file',
|
||||
...(part.fileId ? { fileId: part.fileId } : {}),
|
||||
...(part.fileUrl ? { fileUrl: part.fileUrl } : {}),
|
||||
...(part.fileData ? { fileData: part.fileData } : {}),
|
||||
...(part.filename ? { filename: part.filename } : {}),
|
||||
...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}),
|
||||
});
|
||||
contentBlocks.push(toOpenAiChatFileBlock(normalizedFile));
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_call') {
|
||||
toolCalls.push({
|
||||
id: part.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: part.argumentsJson,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_result' && role !== 'tool') {
|
||||
const text = part.resultText
|
||||
?? (typeof part.resultContent === 'string'
|
||||
? part.resultContent
|
||||
: safeJsonStringify(part.resultJson ?? part.resultContent ?? ''));
|
||||
if (text) {
|
||||
visibleText.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length <= 0) {
|
||||
return {
|
||||
content: visibleText.join(''),
|
||||
...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (visibleText.length > 0) {
|
||||
contentBlocks.unshift({
|
||||
type: 'text',
|
||||
text: visibleText.join(''),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: contentBlocks,
|
||||
...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined): unknown {
|
||||
if (!toolChoice) return undefined;
|
||||
if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice;
|
||||
if (toolChoice === 'required') return 'required';
|
||||
if (toolChoice.type === 'raw') return cloneJsonValue(toolChoice.value);
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolChoice.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalRequestToOpenAiChatBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'tool') {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool_result') continue;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: part.toolCallId,
|
||||
content: part.resultContent
|
||||
?? part.resultText
|
||||
?? safeJsonStringify(part.resultJson ?? ''),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const converted = canonicalPartsToOpenAiContent(message.role, message.parts);
|
||||
const nextMessage: Record<string, unknown> = {
|
||||
role: message.role,
|
||||
content: converted.content,
|
||||
};
|
||||
if (message.role === 'assistant' && converted.reasoning) {
|
||||
nextMessage.reasoning_content = converted.reasoning;
|
||||
}
|
||||
if (message.phase) nextMessage.phase = message.phase;
|
||||
if (message.reasoningSignature) nextMessage.reasoning_signature = message.reasoningSignature;
|
||||
if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) {
|
||||
nextMessage.tool_calls = converted.toolCalls;
|
||||
if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array<unknown>).length <= 0) {
|
||||
nextMessage.content = '';
|
||||
}
|
||||
}
|
||||
messages.push(nextMessage);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: request.requestedModel,
|
||||
stream: request.stream,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort;
|
||||
if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens;
|
||||
if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary;
|
||||
const transformerMetadata = isRecord(request.passthrough?.transformerMetadata)
|
||||
? request.passthrough.transformerMetadata as Record<string, unknown>
|
||||
: null;
|
||||
const passthroughInclude = Array.isArray(transformerMetadata?.include)
|
||||
? (transformerMetadata.include as unknown[])
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => item.trim())
|
||||
: [];
|
||||
const mergedInclude = [
|
||||
...(request.reasoning?.includeEncryptedContent ? ['reasoning.encrypted_content'] : []),
|
||||
...passthroughInclude,
|
||||
].filter((item, index, all) => all.indexOf(item) === index);
|
||||
if (mergedInclude.length > 0) body.include = mergedInclude;
|
||||
const metadata = isRecord(request.metadata)
|
||||
? cloneJsonValue(request.metadata)
|
||||
: {};
|
||||
applyOpenAiCompatibleContinuation(body, request.continuation, metadata);
|
||||
if (Array.isArray(request.attachments) && request.attachments.length > 0) {
|
||||
body.attachments = cloneJsonValue(request.attachments);
|
||||
}
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
body.tools = request.tools.map((tool) => {
|
||||
if ('raw' in tool) {
|
||||
const raw = cloneJsonValue(tool.raw) as Record<string, unknown>;
|
||||
if (typeof raw.type !== 'string' || raw.type.trim().length === 0) {
|
||||
raw.type = tool.type;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}),
|
||||
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice);
|
||||
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
||||
|
||||
if (isRecord(request.passthrough)) {
|
||||
for (const [key, value] of Object.entries(request.passthrough)) {
|
||||
if (key === 'transformerMetadata' || body[key] !== undefined) continue;
|
||||
body[key] = cloneJsonValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
@@ -103,6 +103,98 @@ describe('canonical request helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('collects continuation session ids from OpenAI-compatible metadata and custom session fields', () => {
|
||||
const request = canonicalRequestFromOpenAiBody({
|
||||
body: {
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
session_id: 'session-body-1',
|
||||
conversation_id: 'conversation-body-1',
|
||||
metadata: {
|
||||
user_id: 'session-metadata-1',
|
||||
},
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
surface: 'openai-chat',
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
continuation: {
|
||||
sessionId: 'session-metadata-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('collects continuation turnState from OpenAI-compatible metadata namespace', () => {
|
||||
const request = canonicalRequestFromOpenAiBody({
|
||||
body: {
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
metadata: {
|
||||
metapi_turn_state: 'turn-state-1',
|
||||
},
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
surface: 'openai-chat',
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
continuation: {
|
||||
turnState: 'turn-state-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('materializes continuation session ids into metadata.user_id without overwriting explicit metadata', () => {
|
||||
const body = canonicalRequestToOpenAiChatBody({
|
||||
operation: 'generate',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'claude_code',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
sessionId: 'session-bridge-1',
|
||||
promptCacheKey: 'cache-1',
|
||||
},
|
||||
metadata: {
|
||||
existing: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
metadata: {
|
||||
existing: true,
|
||||
user_id: 'session-bridge-1',
|
||||
},
|
||||
prompt_cache_key: 'cache-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('materializes continuation turnState into metadata without overwriting explicit metadata', () => {
|
||||
const body = canonicalRequestToOpenAiChatBody({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
turnState: 'turn-state-2',
|
||||
},
|
||||
metadata: {
|
||||
existing: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
metadata: {
|
||||
existing: true,
|
||||
metapi_turn_state: 'turn-state-2',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses anthropic-shaped tools from compatibility bodies', () => {
|
||||
const request = canonicalRequestFromOpenAiBody({
|
||||
body: {
|
||||
|
||||
@@ -1,602 +1,9 @@
|
||||
import {
|
||||
canonicalAttachmentFromInputFileBlock,
|
||||
canonicalAttachmentToNormalizedInputFile,
|
||||
type CanonicalAttachment,
|
||||
} from './attachments.js';
|
||||
import { normalizeCanonicalReasoningRequest } from './reasoning.js';
|
||||
import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
|
||||
import type {
|
||||
CanonicalContentPart,
|
||||
CanonicalCliProfile,
|
||||
CanonicalContinuation,
|
||||
CanonicalMessage,
|
||||
CanonicalMessageRole,
|
||||
CanonicalOperation,
|
||||
CanonicalReasoningRequest,
|
||||
CanonicalRequestEnvelope,
|
||||
CanonicalSurface,
|
||||
} from './types.js';
|
||||
import { toOpenAiChatFileBlock } from '../shared/inputFile.js';
|
||||
|
||||
export type CreateCanonicalRequestEnvelopeInput = {
|
||||
operation?: CanonicalOperation;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
requestedModel: string;
|
||||
stream?: boolean;
|
||||
messages?: CanonicalMessage[];
|
||||
reasoning?: CanonicalReasoningRequest;
|
||||
tools?: CanonicalTool[];
|
||||
toolChoice?: CanonicalToolChoice;
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
attachments?: CanonicalAttachment[];
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCanonicalContinuation(
|
||||
continuation: CanonicalContinuation | undefined,
|
||||
): CanonicalContinuation | undefined {
|
||||
if (!continuation) return undefined;
|
||||
|
||||
const normalized: CanonicalContinuation = {
|
||||
...(asTrimmedString(continuation.sessionId) ? { sessionId: asTrimmedString(continuation.sessionId) } : {}),
|
||||
...(asTrimmedString(continuation.previousResponseId)
|
||||
? { previousResponseId: asTrimmedString(continuation.previousResponseId) }
|
||||
: {}),
|
||||
...(asTrimmedString(continuation.promptCacheKey)
|
||||
? { promptCacheKey: asTrimmedString(continuation.promptCacheKey) }
|
||||
: {}),
|
||||
...(asTrimmedString(continuation.turnState) ? { turnState: asTrimmedString(continuation.turnState) } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function createCanonicalRequestEnvelope(
|
||||
input: CreateCanonicalRequestEnvelopeInput,
|
||||
): CanonicalRequestEnvelope {
|
||||
const requestedModel = asTrimmedString(input.requestedModel);
|
||||
if (!requestedModel) {
|
||||
throw new Error('canonical request requires requestedModel');
|
||||
}
|
||||
|
||||
return {
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
cliProfile: input.cliProfile ?? 'generic',
|
||||
requestedModel,
|
||||
stream: input.stream === true,
|
||||
messages: Array.isArray(input.messages) ? input.messages : [],
|
||||
...(input.reasoning ? { reasoning: input.reasoning } : {}),
|
||||
...(Array.isArray(input.tools) && input.tools.length > 0 ? { tools: input.tools } : {}),
|
||||
...(input.toolChoice !== undefined ? { toolChoice: input.toolChoice } : {}),
|
||||
...(normalizeCanonicalContinuation(input.continuation)
|
||||
? { continuation: normalizeCanonicalContinuation(input.continuation) }
|
||||
: {}),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
...(input.passthrough ? { passthrough: input.passthrough } : {}),
|
||||
...(Array.isArray(input.attachments) && input.attachments.length > 0
|
||||
? { attachments: cloneJsonValue(input.attachments) as CanonicalAttachment[] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
type CanonicalRequestFromOpenAiBodyInput = {
|
||||
body: Record<string, unknown>;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
operation?: CanonicalOperation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
continuation?: CanonicalContinuation;
|
||||
};
|
||||
|
||||
function normalizeRole(value: unknown): CanonicalMessageRole {
|
||||
const role = asTrimmedString(value).toLowerCase();
|
||||
switch (role) {
|
||||
case 'system':
|
||||
case 'developer':
|
||||
case 'assistant':
|
||||
case 'tool':
|
||||
return role;
|
||||
default:
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
||||
function openAiContentToCanonicalParts(content: unknown): CanonicalContentPart[] {
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const parts: CanonicalContentPart[] = [];
|
||||
for (const item of content) {
|
||||
if (typeof item === 'string') {
|
||||
if (item) parts.push({ type: 'text', text: item });
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
||||
const text = asTrimmedString(item.text);
|
||||
if (text) parts.push({ type: 'text', text });
|
||||
continue;
|
||||
}
|
||||
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
||||
const text = asTrimmedString(item.text ?? item.reasoning ?? item.thinking);
|
||||
if (text) parts.push({ type: 'text', text, thought: true });
|
||||
continue;
|
||||
}
|
||||
if (type === 'image_url' && isRecord(item.image_url)) {
|
||||
const url = asTrimmedString(item.image_url.url);
|
||||
if (url) parts.push({ type: 'image', url });
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_image' && isRecord(item.image_url)) {
|
||||
const url = asTrimmedString(item.image_url.url);
|
||||
if (url) parts.push({ type: 'image', url });
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
const attachment = canonicalAttachmentFromInputFileBlock(item);
|
||||
if (attachment) {
|
||||
parts.push({
|
||||
type: 'file',
|
||||
...(attachment.fileId ? { fileId: attachment.fileId } : {}),
|
||||
...(attachment.fileUrl ? { fileUrl: attachment.fileUrl } : {}),
|
||||
...(attachment.fileData ? { fileData: attachment.fileData } : {}),
|
||||
...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}),
|
||||
...(attachment.filename ? { filename: attachment.filename } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefined {
|
||||
if (typeof rawToolChoice === 'string') {
|
||||
const normalized = rawToolChoice.trim().toLowerCase();
|
||||
if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized;
|
||||
if (normalized === 'any') return 'required';
|
||||
return rawToolChoice.trim() ? { type: 'raw', value: rawToolChoice } : undefined;
|
||||
}
|
||||
|
||||
if (!isRecord(rawToolChoice)) return undefined;
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'auto' || type === 'none') return type;
|
||||
if (type === 'any' || type === 'required') return 'required';
|
||||
if (type === 'function') {
|
||||
const name = asTrimmedString(
|
||||
(isRecord(rawToolChoice.function) ? rawToolChoice.function.name : undefined)
|
||||
?? rawToolChoice.name,
|
||||
);
|
||||
return name ? { type: 'tool', name } : undefined;
|
||||
}
|
||||
if (type && type !== 'tool') {
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
const name = asTrimmedString(
|
||||
rawToolChoice.name
|
||||
?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined),
|
||||
);
|
||||
const toolChoiceKeys = Object.keys(rawToolChoice);
|
||||
const hasExtraToolFields = toolChoiceKeys.some((key) => key !== 'type' && key !== 'name' && key !== 'tool');
|
||||
if (hasExtraToolFields) {
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
if (name) return { type: 'tool', name };
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
|
||||
if (!Array.isArray(rawTools)) return undefined;
|
||||
|
||||
const tools: CanonicalTool[] = rawTools
|
||||
.flatMap((item): CanonicalTool[] => {
|
||||
if (!isRecord(item)) return [];
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (itemType === 'function' && isRecord(item.function)) {
|
||||
const name = asTrimmedString(item.function.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(item.function.description)
|
||||
? { description: asTrimmedString(item.function.description) }
|
||||
: {}),
|
||||
...(typeof item.function.strict === 'boolean' ? { strict: item.function.strict } : {}),
|
||||
...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}),
|
||||
}];
|
||||
}
|
||||
|
||||
if ((itemType === '' || itemType === 'tool') && asTrimmedString(item.name)) {
|
||||
return [{
|
||||
name: asTrimmedString(item.name),
|
||||
...(asTrimmedString(item.description)
|
||||
? { description: asTrimmedString(item.description) }
|
||||
: {}),
|
||||
...(typeof item.strict === 'boolean' ? { strict: item.strict } : {}),
|
||||
...(isRecord(item.input_schema)
|
||||
? { inputSchema: cloneJsonValue(item.input_schema) }
|
||||
: (isRecord(item.inputSchema) ? { inputSchema: cloneJsonValue(item.inputSchema) } : {})),
|
||||
}];
|
||||
}
|
||||
|
||||
if (Array.isArray(item.functionDeclarations)) {
|
||||
return item.functionDeclarations.flatMap((declaration) => {
|
||||
if (!isRecord(declaration)) return [];
|
||||
const name = asTrimmedString(declaration.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(declaration.description)
|
||||
? { description: asTrimmedString(declaration.description) }
|
||||
: {}),
|
||||
...(isRecord(declaration.parametersJsonSchema)
|
||||
? { inputSchema: cloneJsonValue(declaration.parametersJsonSchema) }
|
||||
: (isRecord(declaration.parameters) ? { inputSchema: cloneJsonValue(declaration.parameters) } : {})),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (itemType) {
|
||||
return [{
|
||||
type: itemType,
|
||||
raw: cloneJsonValue(item) as Record<string, unknown>,
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
export function canonicalRequestFromOpenAiBody(
|
||||
input: CanonicalRequestFromOpenAiBodyInput,
|
||||
): CanonicalRequestEnvelope {
|
||||
const body = input.body;
|
||||
const metadata = isRecord(input.metadata)
|
||||
? input.metadata
|
||||
: (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined);
|
||||
const attachments = Array.isArray(body.attachments)
|
||||
? cloneJsonValue(body.attachments) as CanonicalAttachment[]
|
||||
: undefined;
|
||||
const messages: CanonicalMessage[] = [];
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
|
||||
for (const rawMessage of rawMessages) {
|
||||
if (!isRecord(rawMessage)) continue;
|
||||
const role = normalizeRole(rawMessage.role);
|
||||
|
||||
if (role === 'tool') {
|
||||
const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id);
|
||||
const rawContent = rawMessage.content;
|
||||
const resultText = typeof rawContent === 'string'
|
||||
? rawContent
|
||||
: (!Array.isArray(rawContent) && !isRecord(rawContent) ? safeJsonStringify(rawContent ?? '') : '');
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: toolCallId || 'tool',
|
||||
...(resultText ? { resultText } : {}),
|
||||
...(Array.isArray(rawContent)
|
||||
? { resultContent: cloneJsonValue(rawContent) as Array<string | Record<string, unknown>> }
|
||||
: (isRecord(rawContent)
|
||||
? { resultContent: [cloneJsonValue(rawContent) as Record<string, unknown>] }
|
||||
: {})),
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = openAiContentToCanonicalParts(rawMessage.content);
|
||||
const toolCalls = Array.isArray(rawMessage.tool_calls) ? rawMessage.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const fn = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const name = asTrimmedString(toolCall.name ?? fn.name);
|
||||
const argumentsJson = typeof fn.arguments === 'string'
|
||||
? fn.arguments
|
||||
: safeJsonStringify(fn.arguments ?? toolCall.arguments ?? {});
|
||||
if (!name) continue;
|
||||
parts.push({
|
||||
type: 'tool_call',
|
||||
id: id || `tool_${parts.length}`,
|
||||
name,
|
||||
argumentsJson,
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role,
|
||||
parts,
|
||||
...(asTrimmedString(rawMessage.phase) ? { phase: asTrimmedString(rawMessage.phase) } : {}),
|
||||
...(asTrimmedString(rawMessage.reasoning_signature)
|
||||
? { reasoningSignature: asTrimmedString(rawMessage.reasoning_signature) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
const reasoningResult = normalizeCanonicalReasoningRequest({
|
||||
include: body.include,
|
||||
reasoning: body.reasoning,
|
||||
reasoning_effort: body.reasoning_effort,
|
||||
reasoning_budget: body.reasoning_budget,
|
||||
reasoning_summary: body.reasoning_summary,
|
||||
});
|
||||
|
||||
const continuation: CanonicalContinuation = {
|
||||
...(normalizeCanonicalContinuation(input.continuation) ?? {}),
|
||||
...(asTrimmedString(body.previous_response_id)
|
||||
? { previousResponseId: asTrimmedString(body.previous_response_id) }
|
||||
: {}),
|
||||
...(asTrimmedString(body.prompt_cache_key)
|
||||
? { promptCacheKey: asTrimmedString(body.prompt_cache_key) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const passthrough = {
|
||||
...(input.passthrough ?? {}),
|
||||
...(typeof body.parallel_tool_calls === 'boolean'
|
||||
? { parallel_tool_calls: body.parallel_tool_calls }
|
||||
: {}),
|
||||
...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}),
|
||||
};
|
||||
|
||||
return createCanonicalRequestEnvelope({
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
cliProfile: input.cliProfile ?? 'generic',
|
||||
requestedModel: asTrimmedString(body.model),
|
||||
stream: body.stream === true,
|
||||
messages,
|
||||
...(reasoningResult.reasoning ? { reasoning: reasoningResult.reasoning } : {}),
|
||||
...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}),
|
||||
...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}),
|
||||
...(Object.keys(continuation).length > 0 ? { continuation } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
...(attachments ? { attachments } : {}),
|
||||
...(Object.keys(passthrough).length > 0 ? { passthrough } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalPartsToOpenAiContent(
|
||||
role: CanonicalMessageRole,
|
||||
parts: CanonicalContentPart[],
|
||||
): { content: string | Array<Record<string, unknown>>; reasoning?: string; toolCalls?: Array<Record<string, unknown>> } {
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const toolCalls: Array<Record<string, unknown>> = [];
|
||||
const visibleText: string[] = [];
|
||||
const reasoningText: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
if (part.thought === true) {
|
||||
reasoningText.push(part.text);
|
||||
} else {
|
||||
visibleText.push(part.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'image') {
|
||||
const url = asTrimmedString(part.url ?? part.dataUrl);
|
||||
if (url) {
|
||||
contentBlocks.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'file') {
|
||||
const normalizedFile = canonicalAttachmentToNormalizedInputFile({
|
||||
kind: 'file',
|
||||
...(part.fileId ? { fileId: part.fileId } : {}),
|
||||
...(part.fileUrl ? { fileUrl: part.fileUrl } : {}),
|
||||
...(part.fileData ? { fileData: part.fileData } : {}),
|
||||
...(part.filename ? { filename: part.filename } : {}),
|
||||
...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}),
|
||||
});
|
||||
contentBlocks.push(toOpenAiChatFileBlock(normalizedFile));
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_call') {
|
||||
toolCalls.push({
|
||||
id: part.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: part.argumentsJson,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_result' && role !== 'tool') {
|
||||
const text = part.resultText
|
||||
?? (typeof part.resultContent === 'string'
|
||||
? part.resultContent
|
||||
: safeJsonStringify(part.resultJson ?? part.resultContent ?? ''));
|
||||
if (text) {
|
||||
visibleText.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length <= 0) {
|
||||
return {
|
||||
content: visibleText.join(''),
|
||||
...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (visibleText.length > 0) {
|
||||
contentBlocks.unshift({
|
||||
type: 'text',
|
||||
text: visibleText.join(''),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: contentBlocks,
|
||||
...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined): unknown {
|
||||
if (!toolChoice) return undefined;
|
||||
if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice;
|
||||
if (toolChoice === 'required') return 'required';
|
||||
if (toolChoice.type === 'raw') return cloneJsonValue(toolChoice.value);
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolChoice.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalRequestToOpenAiChatBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'tool') {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool_result') continue;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: part.toolCallId,
|
||||
content: part.resultContent
|
||||
?? part.resultText
|
||||
?? safeJsonStringify(part.resultJson ?? ''),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const converted = canonicalPartsToOpenAiContent(message.role, message.parts);
|
||||
const nextMessage: Record<string, unknown> = {
|
||||
role: message.role,
|
||||
content: converted.content,
|
||||
};
|
||||
if (message.role === 'assistant' && converted.reasoning) {
|
||||
nextMessage.reasoning_content = converted.reasoning;
|
||||
}
|
||||
if (message.phase) nextMessage.phase = message.phase;
|
||||
if (message.reasoningSignature) nextMessage.reasoning_signature = message.reasoningSignature;
|
||||
if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) {
|
||||
nextMessage.tool_calls = converted.toolCalls;
|
||||
if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array<unknown>).length <= 0) {
|
||||
nextMessage.content = '';
|
||||
}
|
||||
}
|
||||
messages.push(nextMessage);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: request.requestedModel,
|
||||
stream: request.stream,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort;
|
||||
if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens;
|
||||
if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary;
|
||||
const transformerMetadata = isRecord(request.passthrough?.transformerMetadata)
|
||||
? request.passthrough.transformerMetadata as Record<string, unknown>
|
||||
: null;
|
||||
const passthroughInclude = Array.isArray(transformerMetadata?.include)
|
||||
? (transformerMetadata.include as unknown[])
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => item.trim())
|
||||
: [];
|
||||
const mergedInclude = [
|
||||
...(request.reasoning?.includeEncryptedContent ? ['reasoning.encrypted_content'] : []),
|
||||
...passthroughInclude,
|
||||
].filter((item, index, all) => all.indexOf(item) === index);
|
||||
if (mergedInclude.length > 0) body.include = mergedInclude;
|
||||
if (request.metadata !== undefined) body.metadata = cloneJsonValue(request.metadata);
|
||||
if (Array.isArray(request.attachments) && request.attachments.length > 0) {
|
||||
body.attachments = cloneJsonValue(request.attachments);
|
||||
}
|
||||
if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey;
|
||||
if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId;
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
body.tools = request.tools.map((tool) => {
|
||||
if ('raw' in tool) {
|
||||
const raw = cloneJsonValue(tool.raw) as Record<string, unknown>;
|
||||
if (typeof raw.type !== 'string' || raw.type.trim().length === 0) {
|
||||
raw.type = tool.type;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}),
|
||||
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice);
|
||||
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
||||
|
||||
if (isRecord(request.passthrough)) {
|
||||
for (const [key, value] of Object.entries(request.passthrough)) {
|
||||
if (key === 'transformerMetadata' || body[key] !== undefined) continue;
|
||||
body[key] = cloneJsonValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export { createCanonicalRequestEnvelope } from './envelope.js';
|
||||
export { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from './openAiRequestBridge.js';
|
||||
export * from './attachments.js';
|
||||
export * from './continuationBridge.js';
|
||||
export * from './envelope.js';
|
||||
export * from './openAiRequestBridge.js';
|
||||
export * from './reasoning.js';
|
||||
export * from './tools.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
wrapGeminiCliRequest,
|
||||
} from './cliBridge.js';
|
||||
|
||||
describe('gemini cli bridge', () => {
|
||||
it('wraps Gemini CLI requests with project and stripped request model', () => {
|
||||
expect(wrapGeminiCliRequest({
|
||||
modelName: 'models/gemini-2.5-pro',
|
||||
projectId: 'project-123',
|
||||
request: {
|
||||
model: 'ignored-model',
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
},
|
||||
})).toEqual({
|
||||
project: 'project-123',
|
||||
model: 'models/gemini-2.5-pro',
|
||||
request: {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('unwraps Gemini CLI payload envelopes to the response body', () => {
|
||||
expect(unwrapGeminiCliPayload({ response: { ok: true }, project: 'p' })).toEqual({ ok: true });
|
||||
expect(unwrapGeminiCliPayload({ ok: true })).toEqual({ ok: true });
|
||||
expect(unwrapGeminiCliPayload('raw')).toBe('raw');
|
||||
});
|
||||
|
||||
it('rewrites SSE data blocks to the inner Gemini CLI response payload', async () => {
|
||||
const chunks = [
|
||||
Buffer.from('data: {"response":{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}}\n\n'),
|
||||
Buffer.from('data: [DONE]\n\n'),
|
||||
];
|
||||
let index = 0;
|
||||
const reader = createGeminiCliStreamReader({
|
||||
async read() {
|
||||
if (index >= chunks.length) return { done: true, value: undefined };
|
||||
return { done: false, value: new Uint8Array(chunks[index++]) };
|
||||
},
|
||||
async cancel() {
|
||||
return undefined;
|
||||
},
|
||||
releaseLock() {},
|
||||
});
|
||||
|
||||
const first = await reader.read();
|
||||
const second = await reader.read();
|
||||
const third = await reader.read();
|
||||
|
||||
expect(Buffer.from(first.value ?? []).toString('utf8')).toContain('data: {"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}');
|
||||
expect(Buffer.from(second.value ?? []).toString('utf8')).toBe('data: [DONE]\n\n');
|
||||
expect(third.done).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TextDecoder, TextEncoder } from 'node:util';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function wrapGeminiCliRequest(input: {
|
||||
modelName: string;
|
||||
projectId: string;
|
||||
request: Record<string, unknown>;
|
||||
}) {
|
||||
const { model: _model, ...requestPayload } = input.request;
|
||||
return {
|
||||
project: input.projectId,
|
||||
model: input.modelName,
|
||||
request: requestPayload,
|
||||
};
|
||||
}
|
||||
|
||||
export function unwrapGeminiCliPayload<T>(payload: T): unknown {
|
||||
if (!isRecord(payload)) return payload;
|
||||
if (payload.response !== undefined) {
|
||||
return payload.response;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function rewriteGeminiCliSseEventBlock(block: string): string {
|
||||
const lines = block.split(/\r?\n/g);
|
||||
return lines.map((line) => {
|
||||
if (!line.startsWith('data:')) return line;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') return line;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`;
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function createGeminiCliStreamReader(reader: {
|
||||
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
||||
cancel(reason?: unknown): Promise<unknown>;
|
||||
releaseLock(): void;
|
||||
}) {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const outputQueue: Uint8Array[] = [];
|
||||
let buffer = '';
|
||||
let done = false;
|
||||
|
||||
async function fillQueue() {
|
||||
while (outputQueue.length <= 0 && !done) {
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
done = true;
|
||||
const tail = decoder.decode();
|
||||
if (tail) buffer += tail;
|
||||
if (buffer.trim()) {
|
||||
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`));
|
||||
buffer = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!result.value) continue;
|
||||
buffer += decoder.decode(result.value, { stream: true });
|
||||
let separatorIndex = buffer.indexOf('\n\n');
|
||||
while (separatorIndex >= 0) {
|
||||
const block = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`));
|
||||
separatorIndex = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async read() {
|
||||
await fillQueue();
|
||||
if (outputQueue.length > 0) {
|
||||
return { done: false, value: outputQueue.shift() };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
cancel(reason?: unknown) {
|
||||
return reader.cancel(reason);
|
||||
},
|
||||
releaseLock() {
|
||||
reader.releaseLock();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
canonicalRequestFromOpenAiBody,
|
||||
isCanonicalFunctionTool,
|
||||
isCanonicalNamedToolChoice,
|
||||
} from '../../canonical/request.js';
|
||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import {
|
||||
resolveGeminiGenerateContentUrl,
|
||||
@@ -20,12 +15,8 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string {
|
||||
return asTrimmedString(params?.geminiApiVersion) || 'v1beta';
|
||||
return (typeof params?.geminiApiVersion === 'string' ? params.geminiApiVersion.trim() : '') || 'v1beta';
|
||||
}
|
||||
|
||||
export function parseGeminiProxyRequestPath(input: {
|
||||
@@ -61,203 +52,21 @@ export function parseGeminiProxyRequestPath(input: {
|
||||
}
|
||||
|
||||
import { geminiGenerateContentInbound } from './inbound.js';
|
||||
import { geminiGenerateContentOutbound } from './outbound.js';
|
||||
import { geminiGenerateContentStream } from './stream.js';
|
||||
import { geminiGenerateContentResponseBridge } from './responseBridge.js';
|
||||
import { geminiGenerateContentStream } from './streamBridge.js';
|
||||
import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js';
|
||||
import { geminiGenerateContentUsage } from './usage.js';
|
||||
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
|
||||
import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js';
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { raw };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataUrl(value: string): { mimeType: string; data: string } | null {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1],
|
||||
data: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalPartToGeminiPart(part: CanonicalContentPart): Record<string, unknown> | null {
|
||||
if (part.type === 'text') {
|
||||
return {
|
||||
text: part.text,
|
||||
...(part.thought === true ? { thought: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image') {
|
||||
const source = typeof part.dataUrl === 'string' && part.dataUrl.trim()
|
||||
? part.dataUrl
|
||||
: (typeof part.url === 'string' ? part.url : '');
|
||||
if (!source) return null;
|
||||
|
||||
const dataUrl = parseDataUrl(source);
|
||||
if (dataUrl) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: dataUrl.mimeType,
|
||||
data: dataUrl.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: source,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'file') {
|
||||
if (part.fileData) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
data: part.fileData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileUri = part.fileUrl || part.fileId;
|
||||
if (!fileUri) return null;
|
||||
return {
|
||||
fileData: {
|
||||
fileUri,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_call') {
|
||||
return {
|
||||
functionCall: {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
args: parseJsonString(part.argumentsJson),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_result') {
|
||||
return {
|
||||
functionResponse: {
|
||||
name: part.toolCallId,
|
||||
response: part.resultJson ?? parseJsonString(part.resultText ?? ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Record<string, unknown> {
|
||||
const contents: Array<Record<string, unknown>> = [];
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'system' || message.role === 'developer') {
|
||||
systemParts.push(
|
||||
...message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part);
|
||||
|
||||
if (parts.length <= 0) continue;
|
||||
|
||||
if (message.role === 'tool') {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: message.role === 'assistant' ? 'model' : 'user',
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
contents,
|
||||
};
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
payload.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
if (request.reasoning?.budgetTokens !== undefined) {
|
||||
generationConfig.thinkingConfig = {
|
||||
thinkingBudget: request.reasoning.budgetTokens,
|
||||
};
|
||||
} else if (request.reasoning?.effort) {
|
||||
generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig(
|
||||
request.requestedModel,
|
||||
request.reasoning.effort,
|
||||
);
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
payload.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
const functionTools = request.tools.filter(isCanonicalFunctionTool);
|
||||
if (functionTools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: functionTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (request.toolChoice) {
|
||||
if (request.toolChoice === 'none') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (request.toolChoice === 'auto') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (request.toolChoice === 'required') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
} else if (isCanonicalNamedToolChoice(request.toolChoice)) {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [request.toolChoice.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
import {
|
||||
buildCanonicalRequestToGeminiGenerateContentBody,
|
||||
parseGeminiGenerateContentRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
export const geminiGenerateContentTransformer = {
|
||||
protocol: 'gemini/generate-content' as const,
|
||||
inbound: geminiGenerateContentInbound,
|
||||
outbound: geminiGenerateContentOutbound,
|
||||
outbound: geminiGenerateContentResponseBridge,
|
||||
stream: geminiGenerateContentStream,
|
||||
aggregator: {
|
||||
createState: createGeminiGenerateContentAggregateState,
|
||||
@@ -281,53 +90,19 @@ export const geminiGenerateContentTransformer = {
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const rawBody = isRecord(body) ? body : {};
|
||||
const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'model is required',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stream = rawBody.stream === true || ctx?.metadata?.stream === true;
|
||||
const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel);
|
||||
const openAiBody = buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody,
|
||||
modelName: requestedModel,
|
||||
stream,
|
||||
});
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
return parseGeminiGenerateContentRequestToCanonical(body, ctx);
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
return buildGeminiRequestFromCanonical(request);
|
||||
return buildCanonicalRequestToGeminiGenerateContentBody(request);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
geminiGenerateContentInbound,
|
||||
geminiGenerateContentOutbound,
|
||||
geminiGenerateContentResponseBridge as geminiGenerateContentOutbound,
|
||||
geminiGenerateContentStream,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
applyGeminiGenerateContentAggregate,
|
||||
|
||||
@@ -1,279 +1,15 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import {
|
||||
toTransformerMetadataRecord,
|
||||
type TransformerMetadata,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
resolveGeminiGenerateContentUrl,
|
||||
resolveGeminiModelsUrl,
|
||||
resolveGeminiNativeBaseUrl,
|
||||
} from './urlResolver.js';
|
||||
extractGeminiGenerateContentResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata,
|
||||
geminiGenerateContentResponseBridge,
|
||||
serializeGeminiGenerateContentAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
export const geminiGenerateContentOutbound = geminiGenerateContentResponseBridge;
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
|
||||
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
|
||||
}
|
||||
|
||||
function ensureAggregateState(payload: unknown): GeminiGenerateContentAggregateState {
|
||||
if (isAggregateState(payload)) return payload;
|
||||
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
const chunks = Array.isArray(payload) ? payload : [payload];
|
||||
for (const chunk of chunks) {
|
||||
applyGeminiGenerateContentAggregate(state, chunk);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildUsageMetadata(state: GeminiGenerateContentAggregateState): GeminiRecord | undefined {
|
||||
const usage = state.usage;
|
||||
const next: GeminiRecord = {};
|
||||
if (typeof usage.promptTokenCount === 'number') next.promptTokenCount = usage.promptTokenCount;
|
||||
if (typeof usage.candidatesTokenCount === 'number') next.candidatesTokenCount = usage.candidatesTokenCount;
|
||||
if (typeof usage.totalTokenCount === 'number') next.totalTokenCount = usage.totalTokenCount;
|
||||
if (typeof usage.cachedContentTokenCount === 'number') next.cachedContentTokenCount = usage.cachedContentTokenCount;
|
||||
if (typeof usage.thoughtsTokenCount === 'number') next.thoughtsTokenCount = usage.thoughtsTokenCount;
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => {
|
||||
const next: GeminiRecord = {
|
||||
index: candidate.index,
|
||||
finishReason: candidate.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: candidate.parts,
|
||||
},
|
||||
};
|
||||
if (candidate.groundingMetadata) next.groundingMetadata = candidate.groundingMetadata;
|
||||
if (candidate.citationMetadata) next.citationMetadata = candidate.citationMetadata;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const fallback: GeminiRecord = {
|
||||
index: 0,
|
||||
finishReason: state.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: state.parts,
|
||||
},
|
||||
};
|
||||
if (state.groundingMetadata.length > 0) {
|
||||
fallback.groundingMetadata = state.groundingMetadata[0];
|
||||
}
|
||||
if (state.citations.length > 0) {
|
||||
fallback.citationMetadata = state.citations[0];
|
||||
}
|
||||
return [fallback];
|
||||
}
|
||||
|
||||
function extractOrderedCandidateMetadata(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
key: 'groundingMetadata' | 'citationMetadata',
|
||||
): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate[key])
|
||||
.filter((item): item is GeminiRecord => isRecord(item))
|
||||
.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
const fallback = key === 'groundingMetadata' ? state.groundingMetadata : state.citations;
|
||||
return fallback.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
function extractOrderedThoughtSignatures(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
): {
|
||||
all: string[];
|
||||
preferred: string | undefined;
|
||||
} {
|
||||
const ordered = new Set<string>();
|
||||
const preferredThoughts = new Set<string>();
|
||||
|
||||
const candidates = state.candidates.length > 0
|
||||
? state.candidates.slice().sort((left, right) => left.index - right.index)
|
||||
: [{ index: 0, finishReason: state.finishReason, parts: state.parts }];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const part of candidate.parts) {
|
||||
if (!isRecord(part) || typeof part.thoughtSignature !== 'string' || !part.thoughtSignature.trim()) {
|
||||
continue;
|
||||
}
|
||||
ordered.add(part.thoughtSignature);
|
||||
if (part.thought === true) {
|
||||
preferredThoughts.add(part.thoughtSignature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedList = [...ordered];
|
||||
const preferred = [...preferredThoughts][0]
|
||||
?? (orderedList.length === 1 ? orderedList[0] : undefined);
|
||||
|
||||
return {
|
||||
all: orderedList.length > 0 ? orderedList : [...state.thoughtSignatures],
|
||||
preferred,
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePassthrough(metadata: TransformerMetadata): Record<string, unknown> {
|
||||
if (!metadata.passthrough) metadata.passthrough = {};
|
||||
return metadata.passthrough;
|
||||
}
|
||||
|
||||
function extractRequestSemantics(requestPayload: unknown): TransformerMetadata {
|
||||
if (!isRecord(requestPayload)) return {};
|
||||
|
||||
const metadata: TransformerMetadata = {};
|
||||
const passthrough = ensurePassthrough(metadata);
|
||||
if (requestPayload.systemInstruction !== undefined) {
|
||||
passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||
}
|
||||
if (requestPayload.cachedContent !== undefined) {
|
||||
passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||
}
|
||||
if (requestPayload.safetySettings !== undefined) {
|
||||
metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||
}
|
||||
if (requestPayload.toolConfig !== undefined) {
|
||||
passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||
}
|
||||
|
||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||
if (generationConfig) {
|
||||
const preservedKeys = [
|
||||
'stopSequences',
|
||||
'responseModalities',
|
||||
'responseMimeType',
|
||||
'responseSchema',
|
||||
'candidateCount',
|
||||
'maxOutputTokens',
|
||||
'temperature',
|
||||
'topP',
|
||||
'topK',
|
||||
'presencePenalty',
|
||||
'frequencyPenalty',
|
||||
'seed',
|
||||
'responseLogprobs',
|
||||
'logprobs',
|
||||
'thinkingConfig',
|
||||
'imageConfig',
|
||||
];
|
||||
for (const key of preservedKeys) {
|
||||
if (generationConfig[key] !== undefined) {
|
||||
if (key === 'imageConfig') {
|
||||
metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]);
|
||||
} else {
|
||||
passthrough[key] = cloneJsonValue(generationConfig[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(requestPayload.tools)) {
|
||||
const requestTools = requestPayload.tools
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch);
|
||||
if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext);
|
||||
if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution);
|
||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations);
|
||||
return next;
|
||||
})
|
||||
.filter((item) => Object.keys(item).length > 0);
|
||||
|
||||
if (requestTools.length > 0) {
|
||||
passthrough.tools = requestTools;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) {
|
||||
delete metadata.passthrough;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function serializeGeminiAggregateResponse(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): GeminiRecord {
|
||||
const state = ensureAggregateState(payload);
|
||||
const response: GeminiRecord = {
|
||||
responseId: state.responseId || '',
|
||||
modelVersion: state.modelVersion || '',
|
||||
candidates: buildCandidates(state),
|
||||
};
|
||||
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) {
|
||||
response.usageMetadata = usageMetadata;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function extractTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata {
|
||||
const state = ensureAggregateState(payload);
|
||||
const metadata = extractRequestSemantics(requestPayload);
|
||||
|
||||
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
|
||||
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
|
||||
const thoughtSignatures = extractOrderedThoughtSignatures(state);
|
||||
|
||||
if (citations.length > 0) metadata.citations = citations;
|
||||
if (groundingMetadata.length > 0) metadata.groundingMetadata = groundingMetadata;
|
||||
if (thoughtSignatures.all.length > 0) {
|
||||
if (thoughtSignatures.preferred) {
|
||||
metadata.thoughtSignature = thoughtSignatures.preferred;
|
||||
}
|
||||
metadata.thoughtSignatures = thoughtSignatures.all;
|
||||
}
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) metadata.usageMetadata = usageMetadata;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
||||
return toTransformerMetadataRecord(extractTransformerMetadata(payload, requestPayload)) ?? {};
|
||||
}
|
||||
|
||||
export const geminiGenerateContentOutbound = {
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
extractTransformerMetadata,
|
||||
extractResponseMetadata,
|
||||
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
||||
};
|
||||
export {
|
||||
geminiGenerateContentResponseBridge,
|
||||
extractGeminiGenerateContentResponseMetadata as extractResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata as extractTransformerMetadata,
|
||||
serializeGeminiGenerateContentAggregateResponse as serializeGeminiAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGeminiGenerateContentRequestFromOpenAi,
|
||||
buildCanonicalRequestToGeminiGenerateContentBody,
|
||||
parseGeminiGenerateContentRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
describe('gemini generate-content request bridge', () => {
|
||||
it('parses Gemini generateContent bodies into canonical envelopes', () => {
|
||||
const result = parseGeminiGenerateContentRequestToCanonical({
|
||||
model: 'gemini-2.5-pro',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
surface: 'gemini-generate-content',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Gemini generateContent bodies from canonical envelopes', () => {
|
||||
const body = buildCanonicalRequestToGeminiGenerateContentBody({
|
||||
operation: 'generate',
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: 'gemini_cli',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
tools: [{ name: 'lookup', inputSchema: { type: 'object' } }],
|
||||
toolChoice: 'required',
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookup',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
},
|
||||
},
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('injects provider thought signatures into functionCall parts', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me check.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
|
||||
provider_specific_fields: { thought_signature: 'real_sig_abc' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(functionCallParts[0].thoughtSignature).toBe('real_sig_abc');
|
||||
});
|
||||
|
||||
it('splits text and signed functionCall parts into separate model messages', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read the file.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'I will read it.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_456',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/tmp/x"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_split_test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_456', content: 'file content here' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
|
||||
expect(modelMessages).toHaveLength(2);
|
||||
expect((modelMessages[0].parts as Array<Record<string, unknown>>).every((part) => 'text' in part)).toBe(true);
|
||||
expect((modelMessages[1].parts as Array<Record<string, unknown>>).every((part) => 'functionCall' in part)).toBe(true);
|
||||
expect((modelMessages[1].parts as Array<Record<string, unknown>>)[0].thoughtSignature).toBe('sig_split_test');
|
||||
});
|
||||
|
||||
it('disables thinking config when signatures are missing for non-gemini targets', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
body: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig_non_gemini',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig_non_gemini', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(functionCallParts[0].thoughtSignature).toBeUndefined();
|
||||
expect((result.generationConfig as Record<string, unknown> | undefined)?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('injects a dummy thought signature when thinking is enabled and no signature is available', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(typeof functionCallParts[0].thoughtSignature).toBe('string');
|
||||
expect((functionCallParts[0].thoughtSignature as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('preserves functionResponse count matching functionCall count', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read two files.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_a',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/a"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_a' },
|
||||
},
|
||||
{
|
||||
id: 'call_b',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/b"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_b' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_a', content: 'content a' },
|
||||
{ role: 'tool', tool_call_id: 'call_b', content: 'content b' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
let functionCallCount = 0;
|
||||
let functionResponseCount = 0;
|
||||
for (const content of contents) {
|
||||
for (const part of (content.parts as Array<Record<string, unknown>>)) {
|
||||
if ('functionCall' in part) functionCallCount += 1;
|
||||
if ('functionResponse' in part) functionResponseCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
expect(functionCallCount).toBe(2);
|
||||
expect(functionResponseCount).toBe(2);
|
||||
});
|
||||
});
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildGeminiGenerateContentRequestFromOpenAi } from './requestBridge.js';
|
||||
|
||||
describe('thoughtSignature injection in OpenAI→Gemini conversion', () => {
|
||||
it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me check.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
|
||||
provider_specific_fields: { thought_signature: 'real_sig_abc' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
// Find the model message with functionCall
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
expect(fcParts[0].thoughtSignature).toBe('real_sig_abc');
|
||||
});
|
||||
|
||||
it('splits text and signed functionCall parts into separate model messages', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read the file.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'I will read it.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_456',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/tmp/x"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_split_test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_456', content: 'file content here' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
|
||||
// Should be 2 model messages: text (unsigned) + functionCall (signed)
|
||||
expect(modelMsgs.length).toBe(2);
|
||||
|
||||
const firstParts = modelMsgs[0].parts as Array<Record<string, unknown>>;
|
||||
const secondParts = modelMsgs[1].parts as Array<Record<string, unknown>>;
|
||||
|
||||
// First: text only, no thoughtSignature
|
||||
expect(firstParts.every((p) => 'text' in p)).toBe(true);
|
||||
expect(firstParts.every((p) => !('thoughtSignature' in p))).toBe(true);
|
||||
|
||||
// Second: functionCall with thoughtSignature
|
||||
expect(secondParts.every((p) => 'functionCall' in p)).toBe(true);
|
||||
expect(secondParts[0].thoughtSignature).toBe('sig_split_test');
|
||||
});
|
||||
|
||||
it('injects dummy sentinel when thinking enabled but no signature available', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
// No provider_specific_fields — signature missing
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
// Should have a dummy sentinel, not be missing
|
||||
expect(typeof fcParts[0].thoughtSignature).toBe('string');
|
||||
expect((fcParts[0].thoughtSignature as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not inject thoughtSignature when thinking is not enabled', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_think',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/x"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_think', content: 'data' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-2.5-flash',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
// No thinking → no signature injected
|
||||
expect(fcParts[0].thoughtSignature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not inject dummy signature or preserve thinkingConfig for non-gemini targets when signature is missing', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig_non_gemini',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig_non_gemini', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
expect(fcParts[0].thoughtSignature).toBeUndefined();
|
||||
expect((result.generationConfig as Record<string, unknown> | undefined)?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves functionResponse count matching functionCall count', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read two files.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_a',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/a"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_a' },
|
||||
},
|
||||
{
|
||||
id: 'call_b',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/b"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_b' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_a', content: 'content a' },
|
||||
{ role: 'tool', tool_call_id: 'call_b', content: 'content b' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
|
||||
// Count functionCall and functionResponse parts
|
||||
let fcCount = 0;
|
||||
let frCount = 0;
|
||||
for (const content of contents) {
|
||||
for (const part of (content.parts as Array<Record<string, unknown>>)) {
|
||||
if ('functionCall' in part) fcCount++;
|
||||
if ('functionResponse' in part) frCount++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(fcCount).toBe(2);
|
||||
expect(frCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
import { canonicalRequestFromOpenAiBody } from '../../canonical/openAiRequestBridge.js';
|
||||
import { isCanonicalFunctionTool, isCanonicalNamedToolChoice } from '../../canonical/tools.js';
|
||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolParseContext } from '../../contracts.js';
|
||||
import { geminiGenerateContentInbound } from './inbound.js';
|
||||
import { buildOpenAiBodyFromGeminiRequest } from './compatibility.js';
|
||||
import {
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
resolveGeminiThinkingConfigFromRequest,
|
||||
} from './convert.js';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { raw };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataUrl(value: string): { mimeType: string; data: string } | null {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1],
|
||||
data: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Dummy sentinel used when no real thoughtSignature is available but thinking
|
||||
// mode is enabled. Gemini accepts any base64 string and won't reject this.
|
||||
const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=';
|
||||
|
||||
function isDummyThoughtSafeModel(modelName: string): boolean {
|
||||
const normalized = asTrimmedString(modelName).toLowerCase();
|
||||
return normalized.startsWith('gemini-') || normalized.startsWith('models/gemini-');
|
||||
}
|
||||
|
||||
function parseInlineDataUrl(url: string): { mimeType: string; data: string } | null {
|
||||
if (!url.startsWith('data:')) return null;
|
||||
const [, rest] = url.split('data:', 2);
|
||||
const [meta, data] = rest.split(',', 2);
|
||||
if (!meta || !data) return null;
|
||||
const [mimeType] = meta.split(';', 1);
|
||||
return {
|
||||
mimeType: mimeType || 'application/octet-stream',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFunctionResponseResult(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function convertOpenAiContentToGeminiParts(content: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof content === 'string') {
|
||||
const trimmed = content.trim();
|
||||
return trimmed ? [{ text: trimmed }] : [];
|
||||
}
|
||||
|
||||
if (isRecord(content)) {
|
||||
if (typeof content.text === 'string') {
|
||||
const trimmed = content.text.trim();
|
||||
return trimmed ? [{ text: trimmed }] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
for (const item of content) {
|
||||
if (!isRecord(item)) continue;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'text') {
|
||||
const text = asTrimmedString(item.text);
|
||||
if (text) parts.push({ text });
|
||||
continue;
|
||||
}
|
||||
if (type === 'image_url') {
|
||||
const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.url);
|
||||
const parsed = imageUrl ? parseInlineDataUrl(imageUrl) : null;
|
||||
if (parsed) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mime_type: parsed.mimeType,
|
||||
data: parsed.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_audio') {
|
||||
const data = asTrimmedString(item.data);
|
||||
if (data) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mime_type: 'audio/wav',
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildGeminiToolsFromOpenAi(tools: unknown): Array<Record<string, unknown>> | undefined {
|
||||
if (!Array.isArray(tools)) return undefined;
|
||||
const declarations = tools
|
||||
.filter((item) => isRecord(item))
|
||||
.flatMap((item) => {
|
||||
if (asTrimmedString(item.type) !== 'function' || !isRecord(item.function)) return [];
|
||||
const fn = item.function as Record<string, unknown>;
|
||||
const name = asTrimmedString(fn.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(fn.description) ? { description: asTrimmedString(fn.description) } : {}),
|
||||
parametersJsonSchema: isRecord(fn.parameters) ? fn.parameters : { type: 'object', properties: {} },
|
||||
}];
|
||||
});
|
||||
|
||||
if (declarations.length <= 0) return undefined;
|
||||
return [{ functionDeclarations: declarations }];
|
||||
}
|
||||
|
||||
function buildGeminiToolConfigFromOpenAi(toolChoice: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof toolChoice === 'string') {
|
||||
const normalized = toolChoice.trim().toLowerCase();
|
||||
if (normalized === 'none') {
|
||||
return { functionCallingConfig: { mode: 'NONE' } };
|
||||
}
|
||||
if (normalized === 'required') {
|
||||
return { functionCallingConfig: { mode: 'ANY' } };
|
||||
}
|
||||
return { functionCallingConfig: { mode: 'AUTO' } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
body: Record<string, unknown>;
|
||||
modelName: string;
|
||||
instructions?: string;
|
||||
}) {
|
||||
const request: Record<string, unknown> = {
|
||||
contents: [],
|
||||
};
|
||||
|
||||
const messages = Array.isArray(input.body.messages) ? input.body.messages : [];
|
||||
const toolNameById = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const name = asTrimmedString(toolCall.function.name);
|
||||
if (id && name) {
|
||||
toolNameById.set(id, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
|
||||
const allowsDummyThoughtSignature = isDummyThoughtSafeModel(input.modelName);
|
||||
let shouldDisableThinkingConfig = false;
|
||||
|
||||
const thoughtSignatureById = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
if (!id) continue;
|
||||
const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null;
|
||||
if (providerFields && typeof providerFields.thought_signature === 'string') {
|
||||
thoughtSignatureById.set(id, providerFields.thought_signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
if (typeof input.instructions === 'string' && input.instructions.trim()) {
|
||||
systemParts.push({ text: input.instructions.trim() });
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message)) continue;
|
||||
const role = asTrimmedString(message.role).toLowerCase();
|
||||
if (role === 'system' || role === 'developer') {
|
||||
systemParts.push(...convertOpenAiContentToGeminiParts(message.content));
|
||||
continue;
|
||||
}
|
||||
if (role === 'tool') {
|
||||
const toolCallId = asTrimmedString(message.tool_call_id);
|
||||
const name = toolNameById.get(toolCallId) || 'unknown';
|
||||
const result = normalizeFunctionResponseResult(message.content);
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{
|
||||
functionResponse: {
|
||||
name,
|
||||
response: {
|
||||
result,
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
const textParts = convertOpenAiContentToGeminiParts(message.content);
|
||||
const fcParts: Array<Record<string, unknown>> = [];
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
|
||||
const name = asTrimmedString(toolCall.function.name);
|
||||
if (!name) continue;
|
||||
const rawArguments = toolCall.function.arguments;
|
||||
let args: unknown = {};
|
||||
if (typeof rawArguments === 'string' && rawArguments.trim()) {
|
||||
try {
|
||||
args = JSON.parse(rawArguments);
|
||||
} catch {
|
||||
args = { raw: rawArguments };
|
||||
}
|
||||
} else if (isRecord(rawArguments)) {
|
||||
args = rawArguments;
|
||||
}
|
||||
const fcPart: Record<string, unknown> = {
|
||||
functionCall: { name, args },
|
||||
};
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const signature = thoughtSignatureById.get(id);
|
||||
if (signature) {
|
||||
fcPart.thoughtSignature = signature;
|
||||
} else if (hasThinkingEnabled && allowsDummyThoughtSignature) {
|
||||
fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE;
|
||||
} else if (hasThinkingEnabled) {
|
||||
shouldDisableThinkingConfig = true;
|
||||
}
|
||||
fcParts.push(fcPart);
|
||||
}
|
||||
|
||||
const geminiRole = role === 'assistant' ? 'model' : 'user';
|
||||
const hasSigned = fcParts.some((part) => 'thoughtSignature' in part);
|
||||
if (hasSigned && textParts.length > 0 && fcParts.length > 0) {
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{ role: geminiRole, parts: textParts },
|
||||
{ role: geminiRole, parts: fcParts },
|
||||
];
|
||||
} else {
|
||||
const allParts = [...textParts, ...fcParts];
|
||||
if (allParts.length <= 0) continue;
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{ role: geminiRole, parts: allParts },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
request.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
const maxOutputTokens = Number(
|
||||
input.body.max_output_tokens
|
||||
?? input.body.max_completion_tokens
|
||||
?? input.body.max_tokens
|
||||
?? 0,
|
||||
);
|
||||
if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
|
||||
generationConfig.maxOutputTokens = Math.trunc(maxOutputTokens);
|
||||
}
|
||||
const temperature = Number(input.body.temperature);
|
||||
if (Number.isFinite(temperature)) generationConfig.temperature = temperature;
|
||||
const topP = Number(input.body.top_p);
|
||||
if (Number.isFinite(topP)) generationConfig.topP = topP;
|
||||
const topK = Number(input.body.top_k);
|
||||
if (Number.isFinite(topK)) generationConfig.topK = topK;
|
||||
if (Array.isArray(input.body.stop) && input.body.stop.length > 0) {
|
||||
generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
|
||||
if (thinkingConfig && !shouldDisableThinkingConfig) {
|
||||
generationConfig.thinkingConfig = thinkingConfig;
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
request.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
const geminiTools = buildGeminiToolsFromOpenAi(input.body.tools);
|
||||
if (geminiTools) {
|
||||
request.tools = geminiTools;
|
||||
}
|
||||
const toolConfig = buildGeminiToolConfigFromOpenAi(input.body.tool_choice);
|
||||
if (toolConfig) {
|
||||
request.toolConfig = toolConfig;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
function canonicalPartToGeminiPart(part: CanonicalContentPart): Record<string, unknown> | null {
|
||||
if (part.type === 'text') {
|
||||
return {
|
||||
text: part.text,
|
||||
...(part.thought === true ? { thought: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image') {
|
||||
const source = typeof part.dataUrl === 'string' && part.dataUrl.trim()
|
||||
? part.dataUrl
|
||||
: (typeof part.url === 'string' ? part.url : '');
|
||||
if (!source) return null;
|
||||
|
||||
const dataUrl = parseDataUrl(source);
|
||||
if (dataUrl) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: dataUrl.mimeType,
|
||||
data: dataUrl.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: source,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'file') {
|
||||
if (part.fileData) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
data: part.fileData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileUri = part.fileUrl || part.fileId;
|
||||
if (!fileUri) return null;
|
||||
return {
|
||||
fileData: {
|
||||
fileUri,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_call') {
|
||||
return {
|
||||
functionCall: {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
args: parseJsonString(part.argumentsJson),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_result') {
|
||||
return {
|
||||
functionResponse: {
|
||||
name: part.toolCallId,
|
||||
response: part.resultJson ?? parseJsonString(part.resultText ?? ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCanonicalRequestToGeminiGenerateContentBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const contents: Array<Record<string, unknown>> = [];
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'system' || message.role === 'developer') {
|
||||
systemParts.push(
|
||||
...message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part);
|
||||
|
||||
if (parts.length <= 0) continue;
|
||||
|
||||
if (message.role === 'tool') {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: message.role === 'assistant' ? 'model' : 'user',
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
contents,
|
||||
};
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
payload.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
if (request.reasoning?.budgetTokens !== undefined) {
|
||||
generationConfig.thinkingConfig = {
|
||||
thinkingBudget: request.reasoning.budgetTokens,
|
||||
};
|
||||
} else if (request.reasoning?.effort) {
|
||||
generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig(
|
||||
request.requestedModel,
|
||||
request.reasoning.effort,
|
||||
);
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
payload.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
const functionTools = request.tools.filter(isCanonicalFunctionTool);
|
||||
if (functionTools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: functionTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (request.toolChoice) {
|
||||
if (request.toolChoice === 'none') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (request.toolChoice === 'auto') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (request.toolChoice === 'required') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
} else if (isCanonicalNamedToolChoice(request.toolChoice)) {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [request.toolChoice.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function parseGeminiGenerateContentRequestToCanonical(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const rawBody = isRecord(body) ? body : {};
|
||||
const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'model is required',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stream = rawBody.stream === true || ctx?.metadata?.stream === true;
|
||||
const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel);
|
||||
const openAiBody = buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody,
|
||||
modelName: requestedModel,
|
||||
stream,
|
||||
});
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractGeminiGenerateContentResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata,
|
||||
geminiGenerateContentResponseBridge,
|
||||
serializeGeminiGenerateContentAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
import { geminiGenerateContentOutbound } from './outbound.js';
|
||||
|
||||
describe('gemini generate-content response bridge', () => {
|
||||
it('serializes aggregate responses with usage metadata', () => {
|
||||
const payload = serializeGeminiGenerateContentAggregateResponse({
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
finishReason: 'STOP',
|
||||
parts: [{ text: 'hello' }],
|
||||
candidates: [],
|
||||
groundingMetadata: [],
|
||||
citations: [],
|
||||
thoughtSignatures: [],
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
}],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts transformer and response metadata from aggregate payloads', () => {
|
||||
const payload = {
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
finishReason: 'STOP',
|
||||
parts: [{ text: 'hello', thoughtSignature: 'sig-1', thought: true }],
|
||||
candidates: [],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractGeminiGenerateContentTransformerMetadata(payload, {
|
||||
systemInstruction: { parts: [{ text: 'system prompt' }] },
|
||||
})).toMatchObject({
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
thoughtSignature: 'sig-1',
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
passthrough: {
|
||||
systemInstruction: { parts: [{ text: 'system prompt' }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(extractGeminiGenerateContentResponseMetadata(payload)).toMatchObject({
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
thoughtSignature: 'sig-1',
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the outbound facade pointed at the response bridge object', () => {
|
||||
expect(geminiGenerateContentOutbound).toBe(geminiGenerateContentResponseBridge);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import {
|
||||
toTransformerMetadataRecord,
|
||||
type TransformerMetadata,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
resolveGeminiGenerateContentUrl,
|
||||
resolveGeminiModelsUrl,
|
||||
resolveGeminiNativeBaseUrl,
|
||||
} from './urlResolver.js';
|
||||
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
|
||||
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
|
||||
}
|
||||
|
||||
function ensureAggregateState(payload: unknown): GeminiGenerateContentAggregateState {
|
||||
if (isAggregateState(payload)) return payload;
|
||||
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
const chunks = Array.isArray(payload) ? payload : [payload];
|
||||
for (const chunk of chunks) {
|
||||
applyGeminiGenerateContentAggregate(state, chunk);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildUsageMetadata(state: GeminiGenerateContentAggregateState): GeminiRecord | undefined {
|
||||
const usage = state.usage;
|
||||
const next: GeminiRecord = {};
|
||||
if (typeof usage.promptTokenCount === 'number') next.promptTokenCount = usage.promptTokenCount;
|
||||
if (typeof usage.candidatesTokenCount === 'number') next.candidatesTokenCount = usage.candidatesTokenCount;
|
||||
if (typeof usage.totalTokenCount === 'number') next.totalTokenCount = usage.totalTokenCount;
|
||||
if (typeof usage.cachedContentTokenCount === 'number') next.cachedContentTokenCount = usage.cachedContentTokenCount;
|
||||
if (typeof usage.thoughtsTokenCount === 'number') next.thoughtsTokenCount = usage.thoughtsTokenCount;
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => {
|
||||
const next: GeminiRecord = {
|
||||
index: candidate.index,
|
||||
finishReason: candidate.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: candidate.parts,
|
||||
},
|
||||
};
|
||||
if (candidate.groundingMetadata) next.groundingMetadata = candidate.groundingMetadata;
|
||||
if (candidate.citationMetadata) next.citationMetadata = candidate.citationMetadata;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const fallback: GeminiRecord = {
|
||||
index: 0,
|
||||
finishReason: state.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: state.parts,
|
||||
},
|
||||
};
|
||||
if (state.groundingMetadata.length > 0) {
|
||||
fallback.groundingMetadata = state.groundingMetadata[0];
|
||||
}
|
||||
if (state.citations.length > 0) {
|
||||
fallback.citationMetadata = state.citations[0];
|
||||
}
|
||||
return [fallback];
|
||||
}
|
||||
|
||||
function extractOrderedCandidateMetadata(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
key: 'groundingMetadata' | 'citationMetadata',
|
||||
): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate[key])
|
||||
.filter((item): item is GeminiRecord => isRecord(item))
|
||||
.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
const fallback = key === 'groundingMetadata' ? state.groundingMetadata : state.citations;
|
||||
return fallback.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
function extractOrderedThoughtSignatures(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
): {
|
||||
all: string[];
|
||||
preferred: string | undefined;
|
||||
} {
|
||||
const ordered = new Set<string>();
|
||||
const preferredThoughts = new Set<string>();
|
||||
|
||||
const candidates = state.candidates.length > 0
|
||||
? state.candidates.slice().sort((left, right) => left.index - right.index)
|
||||
: [{ index: 0, finishReason: state.finishReason, parts: state.parts }];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const part of candidate.parts) {
|
||||
if (!isRecord(part) || typeof part.thoughtSignature !== 'string' || !part.thoughtSignature.trim()) {
|
||||
continue;
|
||||
}
|
||||
ordered.add(part.thoughtSignature);
|
||||
if (part.thought === true) {
|
||||
preferredThoughts.add(part.thoughtSignature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedList = [...ordered];
|
||||
const preferred = [...preferredThoughts][0]
|
||||
?? (orderedList.length === 1 ? orderedList[0] : undefined);
|
||||
|
||||
return {
|
||||
all: orderedList.length > 0 ? orderedList : [...state.thoughtSignatures],
|
||||
preferred,
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePassthrough(metadata: TransformerMetadata): Record<string, unknown> {
|
||||
if (!metadata.passthrough) metadata.passthrough = {};
|
||||
return metadata.passthrough;
|
||||
}
|
||||
|
||||
function extractRequestSemantics(requestPayload: unknown): TransformerMetadata {
|
||||
if (!isRecord(requestPayload)) return {};
|
||||
|
||||
const metadata: TransformerMetadata = {};
|
||||
const passthrough = ensurePassthrough(metadata);
|
||||
if (requestPayload.systemInstruction !== undefined) {
|
||||
passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||
}
|
||||
if (requestPayload.cachedContent !== undefined) {
|
||||
passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||
}
|
||||
if (requestPayload.safetySettings !== undefined) {
|
||||
metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||
}
|
||||
if (requestPayload.toolConfig !== undefined) {
|
||||
passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||
}
|
||||
|
||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||
if (generationConfig) {
|
||||
const preservedKeys = [
|
||||
'stopSequences',
|
||||
'responseModalities',
|
||||
'responseMimeType',
|
||||
'responseSchema',
|
||||
'candidateCount',
|
||||
'maxOutputTokens',
|
||||
'temperature',
|
||||
'topP',
|
||||
'topK',
|
||||
'presencePenalty',
|
||||
'frequencyPenalty',
|
||||
'seed',
|
||||
'responseLogprobs',
|
||||
'logprobs',
|
||||
'thinkingConfig',
|
||||
'imageConfig',
|
||||
];
|
||||
for (const key of preservedKeys) {
|
||||
if (generationConfig[key] !== undefined) {
|
||||
if (key === 'imageConfig') {
|
||||
metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]);
|
||||
} else {
|
||||
passthrough[key] = cloneJsonValue(generationConfig[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(requestPayload.tools)) {
|
||||
const requestTools = requestPayload.tools
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch);
|
||||
if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext);
|
||||
if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution);
|
||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations);
|
||||
return next;
|
||||
})
|
||||
.filter((item) => Object.keys(item).length > 0);
|
||||
|
||||
if (requestTools.length > 0) {
|
||||
passthrough.tools = requestTools;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) {
|
||||
delete metadata.passthrough;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function serializeGeminiGenerateContentAggregateResponse(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): GeminiRecord {
|
||||
const state = ensureAggregateState(payload);
|
||||
const response: GeminiRecord = {
|
||||
responseId: state.responseId || '',
|
||||
modelVersion: state.modelVersion || '',
|
||||
candidates: buildCandidates(state),
|
||||
};
|
||||
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) {
|
||||
response.usageMetadata = usageMetadata;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function extractGeminiGenerateContentTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata {
|
||||
const state = ensureAggregateState(payload);
|
||||
const metadata = extractRequestSemantics(requestPayload);
|
||||
|
||||
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
|
||||
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
|
||||
const thoughtSignatures = extractOrderedThoughtSignatures(state);
|
||||
|
||||
if (citations.length > 0) metadata.citations = citations;
|
||||
if (groundingMetadata.length > 0) metadata.groundingMetadata = groundingMetadata;
|
||||
if (thoughtSignatures.all.length > 0) {
|
||||
if (thoughtSignatures.preferred) {
|
||||
metadata.thoughtSignature = thoughtSignatures.preferred;
|
||||
}
|
||||
metadata.thoughtSignatures = thoughtSignatures.all;
|
||||
}
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) metadata.usageMetadata = usageMetadata;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function extractGeminiGenerateContentResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
||||
return toTransformerMetadataRecord(extractGeminiGenerateContentTransformerMetadata(payload, requestPayload)) ?? {};
|
||||
}
|
||||
|
||||
export const geminiGenerateContentResponseBridge = {
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
extractTransformerMetadata: extractGeminiGenerateContentTransformerMetadata,
|
||||
extractResponseMetadata: extractGeminiGenerateContentResponseMetadata,
|
||||
serializeAggregateResponse: serializeGeminiGenerateContentAggregateResponse,
|
||||
};
|
||||
|
||||
export const geminiGenerateContentOutbound = geminiGenerateContentResponseBridge;
|
||||
|
||||
export {
|
||||
extractGeminiGenerateContentResponseMetadata as extractResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata as extractTransformerMetadata,
|
||||
serializeGeminiGenerateContentAggregateResponse as serializeGeminiAggregateResponse,
|
||||
};
|
||||
@@ -1,253 +1,21 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import { serializeGeminiAggregateResponse } from './outbound.js';
|
||||
|
||||
type ParsedSsePayloads = {
|
||||
events: unknown[];
|
||||
lines: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type GeminiGenerateContentStreamFormat = 'sse' | 'json';
|
||||
|
||||
type ParsedGeminiStreamPayload = {
|
||||
format: GeminiGenerateContentStreamFormat;
|
||||
events: unknown[];
|
||||
lines?: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & {
|
||||
state: GeminiGenerateContentAggregateState;
|
||||
};
|
||||
|
||||
function serializeSsePayload(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
const events: unknown[] = [];
|
||||
const lines: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor));
|
||||
if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break;
|
||||
|
||||
const boundary = cursor + boundaryMatch.index;
|
||||
const block = buffer.slice(cursor, boundary);
|
||||
const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length);
|
||||
cursor = boundary + boundaryMatch[0].length;
|
||||
|
||||
if (!block.trim()) continue;
|
||||
lines.push(rawBlock);
|
||||
|
||||
const data = block
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (!data || data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
events.push(JSON.parse(data));
|
||||
} catch {
|
||||
// Ignore malformed event payloads so aggregation remains tolerant.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
lines,
|
||||
rest: buffer.slice(cursor),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonArrayPayload(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (isRecord(payload)) return [payload];
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseGeminiStreamPayload(
|
||||
payload: unknown,
|
||||
contentType?: string | null,
|
||||
): ParsedGeminiStreamPayload {
|
||||
if (geminiGenerateContentStream.isSseContentType(contentType)) {
|
||||
const parsed = parseSsePayloads(String(payload ?? ''));
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
lines: parsed.lines,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
const parsedJson = JSON.parse(trimmed);
|
||||
return {
|
||||
format: 'json',
|
||||
events: parseJsonArrayPayload(parsedJson),
|
||||
rest: '',
|
||||
};
|
||||
} catch {
|
||||
const parsed = parseSsePayloads(payload);
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
format: 'json',
|
||||
events: [],
|
||||
rest: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
format: 'json',
|
||||
events: parseJsonArrayPayload(payload),
|
||||
rest: '',
|
||||
};
|
||||
}
|
||||
|
||||
function applyParsedPayloadToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
parsed: ParsedGeminiStreamPayload,
|
||||
): AppliedGeminiStreamPayloads {
|
||||
for (const event of parsed.events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
function applyJsonPayloadToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
): GeminiGenerateContentAggregateState {
|
||||
applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(payload, 'application/json'));
|
||||
return state;
|
||||
}
|
||||
|
||||
function applySsePayloadsToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
buffer: string,
|
||||
): AppliedGeminiStreamPayloads {
|
||||
return applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(buffer, 'text/event-stream'));
|
||||
}
|
||||
|
||||
function consumeUpstreamSseBuffer(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
buffer: string,
|
||||
): AppliedGeminiStreamPayloads & { lines: string[] } {
|
||||
const applied = applySsePayloadsToAggregate(state, buffer);
|
||||
return {
|
||||
...applied,
|
||||
lines: applied.lines ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAggregateJsonPayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): unknown {
|
||||
return serializeGeminiAggregateResponse(payload);
|
||||
}
|
||||
|
||||
function serializeAggregateSsePayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): string {
|
||||
return serializeSsePayload(serializeAggregateJsonPayload(payload));
|
||||
}
|
||||
|
||||
function serializeAggregatePayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
format: GeminiGenerateContentStreamFormat = 'json',
|
||||
): unknown {
|
||||
return format === 'sse'
|
||||
? serializeAggregateSsePayload(payload)
|
||||
: serializeAggregateJsonPayload(payload);
|
||||
}
|
||||
|
||||
function serializeUpstreamJsonPayload(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
streamAction = false,
|
||||
): unknown {
|
||||
if (streamAction) {
|
||||
const events = parseJsonArrayPayload(payload);
|
||||
for (const event of events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
applyJsonPayloadToAggregate(state, payload);
|
||||
return serializeAggregateJsonPayload(state);
|
||||
}
|
||||
|
||||
export const geminiGenerateContentStream = {
|
||||
isSseContentType(contentType: string | null | undefined): boolean {
|
||||
return (contentType || '').toLowerCase().includes('text/event-stream');
|
||||
},
|
||||
|
||||
parseJsonArrayPayload,
|
||||
parseGeminiStreamPayload,
|
||||
parseSsePayloads,
|
||||
serializeSsePayload,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
|
||||
createAggregateState(): GeminiGenerateContentAggregateState {
|
||||
return createGeminiGenerateContentAggregateState();
|
||||
},
|
||||
|
||||
applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState {
|
||||
return applyGeminiGenerateContentAggregate(state, payload);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applyParsedPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
geminiGenerateContentStream,
|
||||
parseGeminiStreamPayload,
|
||||
parseJsonArrayPayload,
|
||||
parseSsePayloads,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
serializeSsePayload,
|
||||
consumeUpstreamSseBuffer,
|
||||
};
|
||||
serializeUpstreamJsonPayload,
|
||||
} from './streamBridge.js';
|
||||
export type {
|
||||
AppliedGeminiStreamPayloads,
|
||||
GeminiGenerateContentStreamFormat,
|
||||
ParsedGeminiStreamPayload,
|
||||
ParsedSsePayloads,
|
||||
};
|
||||
} from './streamBridge.js';
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
consumeUpstreamSseBuffer,
|
||||
geminiGenerateContentStream,
|
||||
parseGeminiStreamPayload,
|
||||
serializeAggregateSsePayload,
|
||||
} from './streamBridge.js';
|
||||
|
||||
describe('gemini generate-content stream bridge', () => {
|
||||
it('parses SSE payloads and preserves trailing rest blocks', () => {
|
||||
const parsed = parseGeminiStreamPayload([
|
||||
'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]}}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'data: {"responseId":"partial"',
|
||||
].join('\n'), 'text/event-stream');
|
||||
|
||||
expect(parsed.format).toBe('sse');
|
||||
expect(parsed.events).toHaveLength(1);
|
||||
expect(parsed.rest).toBe('data: {"responseId":"partial"');
|
||||
});
|
||||
|
||||
it('aggregates upstream SSE blocks while preserving raw lines and serializable final output', () => {
|
||||
const state = geminiGenerateContentStream.createAggregateState();
|
||||
const result = consumeUpstreamSseBuffer(state, [
|
||||
'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
expect(result.lines).toHaveLength(2);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.rest).toBe('');
|
||||
expect(serializeAggregateSsePayload(result.state)).toContain('"responseId":"resp-1"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import { serializeGeminiGenerateContentAggregateResponse } from './responseBridge.js';
|
||||
|
||||
type ParsedSsePayloads = {
|
||||
events: unknown[];
|
||||
lines: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type GeminiGenerateContentStreamFormat = 'sse' | 'json';
|
||||
|
||||
type ParsedGeminiStreamPayload = {
|
||||
format: GeminiGenerateContentStreamFormat;
|
||||
events: unknown[];
|
||||
lines?: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & {
|
||||
state: GeminiGenerateContentAggregateState;
|
||||
};
|
||||
|
||||
function serializeSsePayload(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
const events: unknown[] = [];
|
||||
const lines: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor));
|
||||
if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break;
|
||||
|
||||
const boundary = cursor + boundaryMatch.index;
|
||||
const block = buffer.slice(cursor, boundary);
|
||||
const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length);
|
||||
cursor = boundary + boundaryMatch[0].length;
|
||||
|
||||
if (!block.trim()) continue;
|
||||
lines.push(rawBlock);
|
||||
|
||||
const data = block
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (!data || data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
events.push(JSON.parse(data));
|
||||
} catch {
|
||||
// Ignore malformed event payloads so aggregation remains tolerant.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
lines,
|
||||
rest: buffer.slice(cursor),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonArrayPayload(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (isRecord(payload)) return [payload];
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseGeminiStreamPayload(
|
||||
payload: unknown,
|
||||
contentType?: string | null,
|
||||
): ParsedGeminiStreamPayload {
|
||||
if (geminiGenerateContentStream.isSseContentType(contentType)) {
|
||||
const parsed = parseSsePayloads(String(payload ?? ''));
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
lines: parsed.lines,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
const parsedJson = JSON.parse(trimmed);
|
||||
return {
|
||||
format: 'json',
|
||||
events: parseJsonArrayPayload(parsedJson),
|
||||
rest: '',
|
||||
};
|
||||
} catch {
|
||||
const parsed = parseSsePayloads(payload);
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
format: 'json',
|
||||
events: [],
|
||||
rest: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
format: 'json',
|
||||
events: parseJsonArrayPayload(payload),
|
||||
rest: '',
|
||||
};
|
||||
}
|
||||
|
||||
function applyParsedPayloadToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
parsed: ParsedGeminiStreamPayload,
|
||||
): AppliedGeminiStreamPayloads {
|
||||
for (const event of parsed.events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
function applyJsonPayloadToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
): GeminiGenerateContentAggregateState {
|
||||
applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(payload, 'application/json'));
|
||||
return state;
|
||||
}
|
||||
|
||||
function applySsePayloadsToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
buffer: string,
|
||||
): AppliedGeminiStreamPayloads {
|
||||
return applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(buffer, 'text/event-stream'));
|
||||
}
|
||||
|
||||
function consumeUpstreamSseBuffer(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
buffer: string,
|
||||
): AppliedGeminiStreamPayloads & { lines: string[] } {
|
||||
const applied = applySsePayloadsToAggregate(state, buffer);
|
||||
return {
|
||||
...applied,
|
||||
lines: applied.lines ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAggregateJsonPayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): unknown {
|
||||
return serializeGeminiGenerateContentAggregateResponse(payload);
|
||||
}
|
||||
|
||||
function serializeAggregateSsePayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): string {
|
||||
return serializeSsePayload(serializeAggregateJsonPayload(payload));
|
||||
}
|
||||
|
||||
function serializeAggregatePayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
format: GeminiGenerateContentStreamFormat = 'json',
|
||||
): unknown {
|
||||
return format === 'sse'
|
||||
? serializeAggregateSsePayload(payload)
|
||||
: serializeAggregateJsonPayload(payload);
|
||||
}
|
||||
|
||||
function serializeUpstreamJsonPayload(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
streamAction = false,
|
||||
): unknown {
|
||||
if (streamAction) {
|
||||
const events = parseJsonArrayPayload(payload);
|
||||
for (const event of events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
applyJsonPayloadToAggregate(state, payload);
|
||||
return serializeAggregateJsonPayload(state);
|
||||
}
|
||||
|
||||
export const geminiGenerateContentStream = {
|
||||
isSseContentType(contentType: string | null | undefined): boolean {
|
||||
return (contentType || '').toLowerCase().includes('text/event-stream');
|
||||
},
|
||||
|
||||
parseJsonArrayPayload,
|
||||
parseGeminiStreamPayload,
|
||||
parseSsePayloads,
|
||||
serializeSsePayload,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
|
||||
createAggregateState(): GeminiGenerateContentAggregateState {
|
||||
return createGeminiGenerateContentAggregateState();
|
||||
},
|
||||
|
||||
applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState {
|
||||
return applyGeminiGenerateContentAggregate(state, payload);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
parseGeminiStreamPayload,
|
||||
parseJsonArrayPayload,
|
||||
parseSsePayloads,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
serializeSsePayload,
|
||||
consumeUpstreamSseBuffer,
|
||||
};
|
||||
export type {
|
||||
AppliedGeminiStreamPayloads,
|
||||
GeminiGenerateContentStreamFormat,
|
||||
ParsedGeminiStreamPayload,
|
||||
ParsedSsePayloads,
|
||||
};
|
||||
@@ -71,6 +71,45 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips continuation turnState through chat metadata bridging', () => {
|
||||
const built = openAiChatTransformer.buildProtocolRequest({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
sessionId: 'chat-session-1',
|
||||
turnState: 'turn-state-chat-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(built).toMatchObject({
|
||||
metadata: {
|
||||
user_id: 'chat-session-1',
|
||||
metapi_turn_state: 'turn-state-chat-1',
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = openAiChatTransformer.parseRequest({
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
metadata: {
|
||||
user_id: 'chat-session-1',
|
||||
metapi_turn_state: 'turn-state-chat-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.value).toMatchObject({
|
||||
continuation: {
|
||||
sessionId: 'chat-session-1',
|
||||
turnState: 'turn-state-chat-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('captures chat request metadata fields without changing upstream body', () => {
|
||||
const result = openAiChatTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
@@ -277,6 +316,65 @@ describe('openAiChatTransformer.outbound', () => {
|
||||
expect((normalized as any).citations).toEqual(['https://shared.example/citation']);
|
||||
});
|
||||
|
||||
it('maps final Responses payload terminal statuses to sub2api-like chat finish reasons', () => {
|
||||
const incompleteStop = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'resp_final_stop',
|
||||
object: 'response',
|
||||
model: 'gpt-5',
|
||||
status: 'incomplete',
|
||||
output: [],
|
||||
}, 'gpt-5');
|
||||
|
||||
const incompleteLength = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'resp_final_length',
|
||||
object: 'response',
|
||||
model: 'gpt-5',
|
||||
status: 'incomplete',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
output: [],
|
||||
}, 'gpt-5');
|
||||
|
||||
const failedStop = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'resp_final_failed',
|
||||
object: 'response',
|
||||
model: 'gpt-5',
|
||||
status: 'failed',
|
||||
output: [],
|
||||
}, 'gpt-5');
|
||||
|
||||
expect(openAiChatTransformer.serializeFinalResponse(incompleteStop, {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
})).toMatchObject({
|
||||
choices: [{
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
});
|
||||
|
||||
expect(openAiChatTransformer.serializeFinalResponse(incompleteLength, {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
})).toMatchObject({
|
||||
choices: [{
|
||||
finish_reason: 'length',
|
||||
}],
|
||||
});
|
||||
|
||||
expect(openAiChatTransformer.serializeFinalResponse(failedStop, {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
})).toMatchObject({
|
||||
choices: [{
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => {
|
||||
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'chatcmpl-multi',
|
||||
@@ -481,6 +579,67 @@ describe('openAiChatTransformer.stream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes response.incomplete terminal events with sub2api-like finish reasons', () => {
|
||||
const context = openAiChatTransformer.createStreamContext('gpt-5');
|
||||
|
||||
const stopEvent = openAiChatTransformer.transformStreamEvent({
|
||||
type: 'response.incomplete',
|
||||
response: {
|
||||
id: 'resp-incomplete-stop',
|
||||
model: 'gpt-5',
|
||||
status: 'incomplete',
|
||||
},
|
||||
}, context, 'gpt-5');
|
||||
|
||||
const stopPayloads = parseSsePayloads(
|
||||
openAiChatTransformer.serializeStreamEvent(stopEvent, context, createClaudeDownstreamContext()),
|
||||
);
|
||||
|
||||
expect((stopPayloads[0] as any).choices[0]).toMatchObject({
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
|
||||
const lengthEvent = openAiChatTransformer.transformStreamEvent({
|
||||
type: 'response.incomplete',
|
||||
response: {
|
||||
id: 'resp-incomplete-length',
|
||||
model: 'gpt-5',
|
||||
status: 'incomplete',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
},
|
||||
}, context, 'gpt-5');
|
||||
|
||||
const lengthPayloads = parseSsePayloads(
|
||||
openAiChatTransformer.serializeStreamEvent(lengthEvent, context, createClaudeDownstreamContext()),
|
||||
);
|
||||
|
||||
expect((lengthPayloads[0] as any).choices[0]).toMatchObject({
|
||||
finish_reason: 'length',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes response.failed terminal events with stop finish_reason instead of error', () => {
|
||||
const context = openAiChatTransformer.createStreamContext('gpt-5');
|
||||
const event = openAiChatTransformer.transformStreamEvent({
|
||||
type: 'response.failed',
|
||||
response: {
|
||||
id: 'resp-failed-stop',
|
||||
model: 'gpt-5',
|
||||
status: 'failed',
|
||||
},
|
||||
}, context, 'gpt-5');
|
||||
|
||||
const payloads = parseSsePayloads(
|
||||
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
|
||||
);
|
||||
|
||||
expect((payloads[0] as any).choices[0]).toMatchObject({
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes multi-choice stream chunks without collapsing choice-specific deltas', () => {
|
||||
const context = openAiChatTransformer.createStreamContext('gpt-5');
|
||||
const event = openAiChatTransformer.transformStreamEvent({
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
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 {
|
||||
buildCanonicalRequestToOpenAiChatBody,
|
||||
parseOpenAiChatRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
import {
|
||||
openAiChatResponseBridge,
|
||||
buildNormalizedFinalToOpenAiChatChunks,
|
||||
buildNormalizedFinalToOpenAiChatPayload,
|
||||
normalizeOpenAiChatFinalToNormalized,
|
||||
} from './responseBridge.js';
|
||||
import { openAiChatStream } from './streamBridge.js';
|
||||
import { openAiChatUsage } from './usage.js';
|
||||
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
||||
import type {
|
||||
@@ -17,7 +25,7 @@ import type {
|
||||
export const openAiChatTransformer = {
|
||||
protocol: 'openai/chat' as const,
|
||||
inbound: openAiChatInbound,
|
||||
outbound: openAiChatOutbound,
|
||||
outbound: openAiChatResponseBridge,
|
||||
stream: openAiChatStream,
|
||||
usage: openAiChatUsage,
|
||||
compatibility: {
|
||||
@@ -35,41 +43,13 @@ export const openAiChatTransformer = {
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = openAiChatInbound.parse(body);
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid chat request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: parsed.value.parsed.upstreamBody,
|
||||
surface: 'openai-chat',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
return parseOpenAiChatRequestToCanonical(body, ctx);
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
return canonicalRequestToOpenAiChatBody(request);
|
||||
return buildCanonicalRequestToOpenAiChatBody(request);
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
|
||||
return openAiChatInbound.parse(body);
|
||||
@@ -78,7 +58,7 @@ export const openAiChatTransformer = {
|
||||
return openAiChatStream.createContext(modelName);
|
||||
},
|
||||
transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
|
||||
return openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
return normalizeOpenAiChatFinalToNormalized(payload, modelName, fallbackText);
|
||||
},
|
||||
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent {
|
||||
return openAiChatStream.normalizeEvent(payload, context, modelName);
|
||||
@@ -98,9 +78,9 @@ export const openAiChatTransformer = {
|
||||
},
|
||||
serializeFinalResponse(
|
||||
normalized: NormalizedFinalResponse,
|
||||
usage: Parameters<typeof openAiChatOutbound.serializeFinal>[1],
|
||||
usage: Parameters<typeof buildNormalizedFinalToOpenAiChatPayload>[1],
|
||||
) {
|
||||
return openAiChatOutbound.serializeFinal(normalized, usage);
|
||||
return buildNormalizedFinalToOpenAiChatPayload(normalized, usage);
|
||||
},
|
||||
serializeUpstreamFinalAsStream(
|
||||
payload: unknown,
|
||||
@@ -108,16 +88,15 @@ export const openAiChatTransformer = {
|
||||
fallbackText: string,
|
||||
streamContext: StreamTransformContext,
|
||||
) {
|
||||
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
const normalizedFinal = normalizeOpenAiChatFinalToNormalized(payload, modelName, fallbackText);
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
return openAiChatOutbound
|
||||
.buildSyntheticChunks(normalizedFinal)
|
||||
return buildNormalizedFinalToOpenAiChatChunks(normalizedFinal)
|
||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`);
|
||||
},
|
||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||
return openAiChatOutbound.buildSyntheticChunks(normalized);
|
||||
return buildNormalizedFinalToOpenAiChatChunks(normalized);
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return openAiChatStream.pullSseEvents(buffer);
|
||||
|
||||
@@ -1,189 +1,7 @@
|
||||
import {
|
||||
buildSyntheticOpenAiChunks,
|
||||
normalizeUpstreamFinalResponse,
|
||||
serializeFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatChoices, extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedFinalResponse } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeUsagePayload(
|
||||
usage: unknown,
|
||||
normalized: OpenAiChatNormalizedFinalResponse,
|
||||
): Record<string, unknown> | undefined {
|
||||
const merged = isRecord(usage) ? { ...usage } : {};
|
||||
const usagePayload = normalized.usagePayload && isRecord(normalized.usagePayload)
|
||||
? normalized.usagePayload
|
||||
: null;
|
||||
|
||||
if (usagePayload) {
|
||||
for (const [key, value] of Object.entries(usagePayload)) {
|
||||
if (key === 'prompt_tokens' || key === 'completion_tokens' || key === 'total_tokens') continue;
|
||||
if (merged[key] === undefined) merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.usageDetails?.prompt_tokens_details) {
|
||||
merged.prompt_tokens_details = normalized.usageDetails.prompt_tokens_details;
|
||||
}
|
||||
if (normalized.usageDetails?.completion_tokens_details) {
|
||||
merged.completion_tokens_details = normalized.usageDetails.completion_tokens_details;
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
export const openAiChatOutbound = {
|
||||
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): OpenAiChatNormalizedFinalResponse {
|
||||
const choices = extractChatChoices(payload);
|
||||
const primaryChoice = choices[0];
|
||||
return {
|
||||
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
||||
...(choices.length > 0 ? { choices } : {}),
|
||||
...(primaryChoice?.annotations?.length ? { annotations: primaryChoice.annotations } : {}),
|
||||
...(primaryChoice?.citations?.length ? { citations: primaryChoice.citations } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) {
|
||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
||||
const payload = serializeFinalResponse(
|
||||
'openai',
|
||||
normalized,
|
||||
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
? chatNormalized.choices
|
||||
: [{
|
||||
index: 0,
|
||||
content: normalized.content,
|
||||
reasoningContent: normalized.reasoningContent,
|
||||
toolCalls: normalized.toolCalls,
|
||||
finishReason: normalized.toolCalls.length > 0 ? 'tool_calls' : normalized.finishReason,
|
||||
annotations: chatNormalized.annotations,
|
||||
citations: chatNormalized.citations,
|
||||
}];
|
||||
|
||||
payload.choices = choices.map((choice) => {
|
||||
const message: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content,
|
||||
};
|
||||
if (choice.reasoningContent) {
|
||||
message.reasoning_content = choice.reasoningContent;
|
||||
}
|
||||
if (Array.isArray(choice.toolCalls) && choice.toolCalls.length > 0) {
|
||||
message.tool_calls = choice.toolCalls.map((toolCall) => ({
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
},
|
||||
}));
|
||||
if (!choice.content) {
|
||||
message.content = '';
|
||||
}
|
||||
}
|
||||
if (Array.isArray(choice.annotations) && choice.annotations.length > 0) {
|
||||
message.annotations = choice.annotations;
|
||||
}
|
||||
|
||||
return {
|
||||
index: choice.index,
|
||||
message,
|
||||
finish_reason: choice.finishReason || (choice.toolCalls?.length ? 'tool_calls' : 'stop'),
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index);
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
payload.citations = chatNormalized.citations;
|
||||
}
|
||||
|
||||
const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized);
|
||||
if (mergedUsage) {
|
||||
payload.usage = {
|
||||
...(isRecord(payload.usage) ? payload.usage : {}),
|
||||
...mergedUsage,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
? chatNormalized.choices
|
||||
: undefined;
|
||||
const chunks = choices && choices.length > 1
|
||||
? [
|
||||
{
|
||||
id: normalized.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => {
|
||||
const delta: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content || '',
|
||||
};
|
||||
if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent;
|
||||
if (choice.toolCalls.length > 0) {
|
||||
delta.tool_calls = choice.toolCalls.map((toolCall, toolIndex) => ({
|
||||
index: toolIndex,
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (Array.isArray(choice.annotations) && choice.annotations.length > 0) {
|
||||
delta.annotations = choice.annotations;
|
||||
}
|
||||
return {
|
||||
index: choice.index,
|
||||
delta,
|
||||
finish_reason: null,
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
{
|
||||
id: normalized.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => ({
|
||||
index: choice.index,
|
||||
delta: {},
|
||||
finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||
})).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
]
|
||||
: buildSyntheticOpenAiChunks(normalized);
|
||||
if (chunks.length <= 0) return chunks;
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
chunks[0] = {
|
||||
...chunks[0],
|
||||
citations: chatNormalized.citations,
|
||||
};
|
||||
}
|
||||
|
||||
if (!choices && Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||
const firstChunk = isRecord(chunks[0]) ? chunks[0] : null;
|
||||
const firstChoice = firstChunk && Array.isArray(firstChunk.choices) ? firstChunk.choices[0] : null;
|
||||
if (isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
||||
firstChoice.delta.annotations = chatNormalized.annotations;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
},
|
||||
};
|
||||
export {
|
||||
openAiChatOutbound,
|
||||
openAiChatResponseBridge,
|
||||
buildNormalizedFinalToOpenAiChatChunks,
|
||||
buildNormalizedFinalToOpenAiChatPayload,
|
||||
normalizeOpenAiChatFinalToNormalized,
|
||||
} from './responseBridge.js';
|
||||
|
||||
@@ -2,8 +2,11 @@ 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';
|
||||
import {
|
||||
buildNormalizedFinalToOpenAiChatChunks,
|
||||
normalizeOpenAiChatFinalToNormalized,
|
||||
} from './responseBridge.js';
|
||||
import { openAiChatStream } from './streamBridge.js';
|
||||
import { config } from '../../../config.js';
|
||||
|
||||
type StreamReader = {
|
||||
@@ -50,7 +53,7 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
let terminalNormalizedFinal: ReturnType<typeof openAiChatOutbound.normalizeFinal> | null = null;
|
||||
let terminalNormalizedFinal: ReturnType<typeof normalizeOpenAiChatFinalToNormalized> | null = null;
|
||||
let forwardedDownstreamOutput = false;
|
||||
const pendingWrites: string[] = [];
|
||||
|
||||
@@ -206,7 +209,7 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
||||
const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values())
|
||||
.some((choice) => !choice.finishReason);
|
||||
if (needsTerminalFinishChunk) {
|
||||
const terminalChunk = openAiChatOutbound.buildSyntheticChunks(
|
||||
const terminalChunk = buildNormalizedFinalToOpenAiChatChunks(
|
||||
finalizeOpenAiChatAggregate(chatAggregateState, {
|
||||
id: streamContext.id,
|
||||
model: streamContext.model,
|
||||
@@ -306,14 +309,13 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
||||
}
|
||||
}
|
||||
if (input.downstreamFormat === 'openai') {
|
||||
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText);
|
||||
const normalizedFinal = normalizeOpenAiChatFinalToNormalized(payload, input.modelName, fallbackText);
|
||||
terminalNormalizedFinal = normalizedFinal;
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
emitLines(
|
||||
openAiChatOutbound
|
||||
.buildSyntheticChunks(normalizedFinal)
|
||||
buildNormalizedFinalToOpenAiChatChunks(normalizedFinal)
|
||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`),
|
||||
{ meaningful: true },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCanonicalRequestToOpenAiChatBody,
|
||||
parseOpenAiChatRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
describe('openai chat request bridge', () => {
|
||||
it('parses OpenAI Chat bodies into canonical envelopes', () => {
|
||||
const result = parseOpenAiChatRequestToCanonical({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning_effort: 'high',
|
||||
reasoning_budget: 1024,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
requestedModel: 'gpt-5',
|
||||
continuation: {
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
budgetTokens: 1024,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds OpenAI Chat bodies from canonical envelopes', () => {
|
||||
const body = buildCanonicalRequestToOpenAiChatBody({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
reasoning: {
|
||||
effort: 'medium',
|
||||
budgetTokens: 512,
|
||||
},
|
||||
continuation: {
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning_effort: 'medium',
|
||||
reasoning_budget: 512,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves assistant reasoning history alongside tool calls when parsing chat requests', () => {
|
||||
const result = parseOpenAiChatRequestToCanonical({
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'plan quietly',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'Glob',
|
||||
arguments: '{"pattern":"README*"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
content: 'README.md',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'plan quietly',
|
||||
thought: true,
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_1',
|
||||
name: 'Glob',
|
||||
argumentsJson: '{"pattern":"README*"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: 'call_1',
|
||||
resultText: 'README.md',
|
||||
}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/openAiRequestBridge.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolParseContext } from '../../contracts.js';
|
||||
import { openAiChatInbound } from './inbound.js';
|
||||
|
||||
export function parseOpenAiChatRequestToCanonical(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = openAiChatInbound.parse(body);
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid chat request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: parsed.value.parsed.upstreamBody,
|
||||
surface: 'openai-chat',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCanonicalRequestToOpenAiChatBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
return canonicalRequestToOpenAiChatBody(request);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildNormalizedFinalToOpenAiChatChunks,
|
||||
buildNormalizedFinalToOpenAiChatPayload,
|
||||
normalizeOpenAiChatFinalToNormalized,
|
||||
openAiChatResponseBridge,
|
||||
} from './responseBridge.js';
|
||||
import { openAiChatOutbound } from './outbound.js';
|
||||
|
||||
describe('openai chat response bridge', () => {
|
||||
it('normalizes final chat payloads into normalized responses', () => {
|
||||
const normalized = normalizeOpenAiChatFinalToNormalized({
|
||||
id: 'chatcmpl-1',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
reasoning_content: 'think',
|
||||
},
|
||||
}],
|
||||
}, 'gpt-5');
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
id: 'chatcmpl-1',
|
||||
model: 'gpt-5',
|
||||
content: 'hello',
|
||||
reasoningContent: 'think',
|
||||
finishReason: 'stop',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds final chat payloads and synthetic chunks from normalized responses', () => {
|
||||
const normalized = {
|
||||
id: 'chatcmpl-1',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
content: 'hello',
|
||||
reasoningContent: 'think',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
};
|
||||
|
||||
const payload = buildNormalizedFinalToOpenAiChatPayload(normalized as any, {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
});
|
||||
const chunks = buildNormalizedFinalToOpenAiChatChunks(normalized as any);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
id: 'chatcmpl-1',
|
||||
model: 'gpt-5',
|
||||
usage: {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
total_tokens: 18,
|
||||
},
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
reasoning_content: 'think',
|
||||
},
|
||||
}],
|
||||
});
|
||||
expect(chunks[0]).toMatchObject({
|
||||
id: 'chatcmpl-1',
|
||||
model: 'gpt-5',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves tool-call semantics when bridging normalized tool-turn finals back to chat payloads', () => {
|
||||
const normalized = {
|
||||
id: 'chatcmpl-tool-1',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
content: '',
|
||||
reasoningContent: 'plan first',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{
|
||||
id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
}],
|
||||
};
|
||||
|
||||
const payload = buildNormalizedFinalToOpenAiChatPayload(normalized as any, {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
});
|
||||
const chunks = buildNormalizedFinalToOpenAiChatChunks(normalized as any);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
id: 'chatcmpl-tool-1',
|
||||
model: 'gpt-5',
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'plan first',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
});
|
||||
expect(chunks[0]).toMatchObject({
|
||||
id: 'chatcmpl-tool-1',
|
||||
model: 'gpt-5',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'plan first',
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
});
|
||||
expect(chunks[1]).toMatchObject({
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: 'tool_calls',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the outbound facade pointed at the response bridge object', () => {
|
||||
expect(openAiChatOutbound).toBe(openAiChatResponseBridge);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
buildSyntheticOpenAiChunks,
|
||||
normalizeUpstreamFinalResponse,
|
||||
serializeFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatChoices, extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedFinalResponse } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeUsagePayload(
|
||||
usage: unknown,
|
||||
normalized: OpenAiChatNormalizedFinalResponse,
|
||||
): Record<string, unknown> | undefined {
|
||||
const merged = isRecord(usage) ? { ...usage } : {};
|
||||
const usagePayload = normalized.usagePayload && isRecord(normalized.usagePayload)
|
||||
? normalized.usagePayload
|
||||
: null;
|
||||
|
||||
if (usagePayload) {
|
||||
for (const [key, value] of Object.entries(usagePayload)) {
|
||||
if (key === 'prompt_tokens' || key === 'completion_tokens' || key === 'total_tokens') continue;
|
||||
if (merged[key] === undefined) merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.usageDetails?.prompt_tokens_details) {
|
||||
merged.prompt_tokens_details = normalized.usageDetails.prompt_tokens_details;
|
||||
}
|
||||
if (normalized.usageDetails?.completion_tokens_details) {
|
||||
merged.completion_tokens_details = normalized.usageDetails.completion_tokens_details;
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
export function normalizeOpenAiChatFinalToNormalized(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText = '',
|
||||
): OpenAiChatNormalizedFinalResponse {
|
||||
const choices = extractChatChoices(payload);
|
||||
const primaryChoice = choices[0];
|
||||
return {
|
||||
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
||||
...(choices.length > 0 ? { choices } : {}),
|
||||
...(primaryChoice?.annotations?.length ? { annotations: primaryChoice.annotations } : {}),
|
||||
...(primaryChoice?.citations?.length ? { citations: primaryChoice.citations } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNormalizedFinalToOpenAiChatPayload(
|
||||
normalized: NormalizedFinalResponse,
|
||||
usage?: unknown,
|
||||
) {
|
||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
||||
const payload = serializeFinalResponse(
|
||||
'openai',
|
||||
normalized,
|
||||
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
? chatNormalized.choices
|
||||
: [{
|
||||
index: 0,
|
||||
content: normalized.content,
|
||||
reasoningContent: normalized.reasoningContent,
|
||||
toolCalls: normalized.toolCalls,
|
||||
finishReason: normalized.toolCalls.length > 0 ? 'tool_calls' : normalized.finishReason,
|
||||
annotations: chatNormalized.annotations,
|
||||
citations: chatNormalized.citations,
|
||||
}];
|
||||
|
||||
payload.choices = choices.map((choice) => {
|
||||
const message: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content,
|
||||
};
|
||||
if (choice.reasoningContent) {
|
||||
message.reasoning_content = choice.reasoningContent;
|
||||
}
|
||||
if (Array.isArray(choice.toolCalls) && choice.toolCalls.length > 0) {
|
||||
message.tool_calls = choice.toolCalls.map((toolCall) => ({
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
},
|
||||
}));
|
||||
if (!choice.content) {
|
||||
message.content = '';
|
||||
}
|
||||
}
|
||||
if (Array.isArray(choice.annotations) && choice.annotations.length > 0) {
|
||||
message.annotations = choice.annotations;
|
||||
}
|
||||
|
||||
return {
|
||||
index: choice.index,
|
||||
message,
|
||||
finish_reason: choice.finishReason || (choice.toolCalls?.length ? 'tool_calls' : 'stop'),
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index);
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
payload.citations = chatNormalized.citations;
|
||||
}
|
||||
|
||||
const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized);
|
||||
if (mergedUsage) {
|
||||
payload.usage = {
|
||||
...(isRecord(payload.usage) ? payload.usage : {}),
|
||||
...mergedUsage,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildNormalizedFinalToOpenAiChatChunks(normalized: NormalizedFinalResponse) {
|
||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
? chatNormalized.choices
|
||||
: undefined;
|
||||
const chunks = choices && choices.length > 1
|
||||
? [
|
||||
{
|
||||
id: normalized.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => {
|
||||
const delta: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content || '',
|
||||
};
|
||||
if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent;
|
||||
if (choice.toolCalls.length > 0) {
|
||||
delta.tool_calls = choice.toolCalls.map((toolCall, toolIndex) => ({
|
||||
index: toolIndex,
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (Array.isArray(choice.annotations) && choice.annotations.length > 0) {
|
||||
delta.annotations = choice.annotations;
|
||||
}
|
||||
return {
|
||||
index: choice.index,
|
||||
delta,
|
||||
finish_reason: null,
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
{
|
||||
id: normalized.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => ({
|
||||
index: choice.index,
|
||||
delta: {},
|
||||
finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||
})).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
]
|
||||
: buildSyntheticOpenAiChunks(normalized);
|
||||
if (chunks.length <= 0) return chunks;
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
chunks[0] = {
|
||||
...chunks[0],
|
||||
citations: chatNormalized.citations,
|
||||
};
|
||||
}
|
||||
|
||||
if (!choices && Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||
const firstChunk = isRecord(chunks[0]) ? chunks[0] : null;
|
||||
const firstChoice = firstChunk && Array.isArray(firstChunk.choices) ? firstChunk.choices[0] : null;
|
||||
if (isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
||||
firstChoice.delta.annotations = chatNormalized.annotations;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export const openAiChatResponseBridge = {
|
||||
normalizeFinal: normalizeOpenAiChatFinalToNormalized,
|
||||
serializeFinal: buildNormalizedFinalToOpenAiChatPayload,
|
||||
buildSyntheticChunks: buildNormalizedFinalToOpenAiChatChunks,
|
||||
};
|
||||
|
||||
export const openAiChatOutbound = openAiChatResponseBridge;
|
||||
|
||||
export {
|
||||
buildNormalizedFinalToOpenAiChatChunks as buildSyntheticOpenAiChatChunksFromNormalized,
|
||||
buildNormalizedFinalToOpenAiChatPayload as serializeOpenAiChatFinalPayload,
|
||||
normalizeOpenAiChatFinalToNormalized as normalizeOpenAiChatFinalPayload,
|
||||
};
|
||||
@@ -1,190 +1 @@
|
||||
import {
|
||||
createClaudeDownstreamContext,
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
serializeNormalizedStreamEvent,
|
||||
serializeStreamDone,
|
||||
type ClaudeDownstreamContext,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatChoiceEvents, extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedStreamEvent } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseSerializedSse(lines: string[]): Array<{ index: number; payload: Record<string, unknown> }> {
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
if (!line.startsWith('data: ') || line.trim() === 'data: [DONE]') return null;
|
||||
try {
|
||||
return {
|
||||
index,
|
||||
payload: JSON.parse(line.slice(6)) as Record<string, unknown>,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item): item is { index: number; payload: Record<string, unknown> } => !!item);
|
||||
}
|
||||
|
||||
export const openAiChatStream = {
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
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 {
|
||||
...normalized,
|
||||
...(primaryChoice
|
||||
? {
|
||||
choiceIndex: primaryChoice.index,
|
||||
annotations: primaryChoice.annotations,
|
||||
citations: primaryChoice.citations,
|
||||
}
|
||||
: {}),
|
||||
...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
serializeEvent(
|
||||
event: OpenAiChatNormalizedStreamEvent,
|
||||
context: StreamTransformContext,
|
||||
downstreamContext?: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
if (
|
||||
Array.isArray(event.choiceEvents)
|
||||
&& event.choiceEvents.length > 0
|
||||
&& (event.choiceEvents.length > 1 || event.choiceEvents[0]?.index !== 0)
|
||||
) {
|
||||
const usagePayload = event.usagePayload
|
||||
? {
|
||||
...(isRecord(event.usagePayload) ? event.usagePayload : {}),
|
||||
...(event.usageDetails?.prompt_tokens_details
|
||||
? { prompt_tokens_details: event.usageDetails.prompt_tokens_details }
|
||||
: {}),
|
||||
...(event.usageDetails?.completion_tokens_details
|
||||
? { completion_tokens_details: event.usageDetails.completion_tokens_details }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
return [`data: ${JSON.stringify({
|
||||
id: context.id || `chatcmpl-${Date.now()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: context.created,
|
||||
model: context.model,
|
||||
choices: event.choiceEvents
|
||||
.map((choiceEvent) => {
|
||||
const delta: Record<string, unknown> = {};
|
||||
if (choiceEvent.role) delta.role = choiceEvent.role;
|
||||
if (choiceEvent.contentDelta !== undefined) delta.content = choiceEvent.contentDelta;
|
||||
if (choiceEvent.reasoningDelta) delta.reasoning_content = choiceEvent.reasoningDelta;
|
||||
if (Array.isArray(choiceEvent.toolCallDeltas) && choiceEvent.toolCallDeltas.length > 0) {
|
||||
delta.tool_calls = choiceEvent.toolCallDeltas.map((toolCall) => ({
|
||||
index: toolCall.index,
|
||||
...(toolCall.id ? { id: toolCall.id } : {}),
|
||||
type: 'function',
|
||||
function: {
|
||||
...(toolCall.name ? { name: toolCall.name } : {}),
|
||||
...(toolCall.argumentsDelta !== undefined ? { arguments: toolCall.argumentsDelta } : {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (Array.isArray(choiceEvent.annotations) && choiceEvent.annotations.length > 0) {
|
||||
delta.annotations = choiceEvent.annotations;
|
||||
}
|
||||
|
||||
return {
|
||||
index: choiceEvent.index,
|
||||
delta,
|
||||
finish_reason: choiceEvent.finishReason ?? null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.index - right.index),
|
||||
...(Array.isArray(event.citations) && event.citations.length > 0 ? { citations: event.citations } : {}),
|
||||
...(usagePayload && Object.keys(usagePayload).length > 0 ? { usage: usagePayload } : {}),
|
||||
})}\n\n`];
|
||||
}
|
||||
|
||||
const lines = serializeNormalizedStreamEvent(
|
||||
'openai',
|
||||
event,
|
||||
context,
|
||||
downstreamContext ?? createClaudeDownstreamContext(),
|
||||
);
|
||||
|
||||
if (
|
||||
(!Array.isArray(event.annotations) || event.annotations.length <= 0)
|
||||
&& (!Array.isArray(event.citations) || event.citations.length <= 0)
|
||||
&& !event.usagePayload
|
||||
&& !event.usageDetails
|
||||
) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const parsedEvents = parseSerializedSse(lines);
|
||||
if (parsedEvents.length <= 0) return lines;
|
||||
|
||||
for (const parsed of parsedEvents) {
|
||||
const payload = parsed.payload;
|
||||
if (Array.isArray(event.citations) && event.citations.length > 0) {
|
||||
payload.citations = event.citations;
|
||||
}
|
||||
|
||||
const firstChoice = Array.isArray(payload.choices) ? payload.choices[0] : null;
|
||||
if (Array.isArray(event.annotations) && event.annotations.length > 0 && isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
||||
firstChoice.delta.annotations = event.annotations;
|
||||
}
|
||||
|
||||
if (event.usagePayload || event.usageDetails) {
|
||||
payload.usage = {
|
||||
...(isRecord(event.usagePayload) ? event.usagePayload : {}),
|
||||
...(event.usageDetails?.prompt_tokens_details
|
||||
? { prompt_tokens_details: event.usageDetails.prompt_tokens_details }
|
||||
: {}),
|
||||
...(event.usageDetails?.completion_tokens_details
|
||||
? { completion_tokens_details: event.usageDetails.completion_tokens_details }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
lines[parsed.index] = `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
return lines;
|
||||
},
|
||||
serializeDone(
|
||||
context: StreamTransformContext,
|
||||
downstreamContext?: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
return serializeStreamDone(
|
||||
'openai',
|
||||
context,
|
||||
downstreamContext ?? createClaudeDownstreamContext(),
|
||||
);
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return pullSseEventsWithDone(buffer);
|
||||
},
|
||||
};
|
||||
export { openAiChatStream } from './streamBridge.js';
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createClaudeDownstreamContext } from '../../shared/normalized.js';
|
||||
import { openAiChatStream } from './streamBridge.js';
|
||||
|
||||
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
||||
return lines
|
||||
.filter((line) => line.startsWith('data: ') && line.trim() !== 'data: [DONE]')
|
||||
.map((line) => JSON.parse(line.slice(6)) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe('openai chat stream bridge', () => {
|
||||
it('normalizes stream payloads with choice metadata', () => {
|
||||
const context = openAiChatStream.createContext('gpt-5');
|
||||
const normalized = openAiChatStream.normalizeEvent({
|
||||
id: 'chatcmpl-stream-1',
|
||||
model: 'gpt-5',
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
reasoning_content: 'think',
|
||||
},
|
||||
}],
|
||||
}, context, 'gpt-5');
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
choiceIndex: 0,
|
||||
role: 'assistant',
|
||||
contentDelta: 'hello',
|
||||
reasoningDelta: 'think',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes stream events and done markers as openai chat chunks', () => {
|
||||
const context = openAiChatStream.createContext('gpt-5');
|
||||
const lines = openAiChatStream.serializeEvent({
|
||||
role: 'assistant',
|
||||
contentDelta: 'hello',
|
||||
} as any, context, createClaudeDownstreamContext());
|
||||
const doneLines = openAiChatStream.serializeDone(context, createClaudeDownstreamContext());
|
||||
|
||||
const payloads = parseSsePayloads(lines);
|
||||
expect(payloads[0]).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
},
|
||||
}],
|
||||
});
|
||||
expect(doneLines.join('')).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('round-trips tool-call deltas without flattening them into plain text', () => {
|
||||
const context = openAiChatStream.createContext('gpt-5');
|
||||
const normalized = openAiChatStream.normalizeEvent({
|
||||
id: 'chatcmpl-stream-tool-1',
|
||||
model: 'gpt-5',
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
}, context, 'gpt-5');
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
choiceIndex: 0,
|
||||
choiceEvents: [{
|
||||
index: 0,
|
||||
role: 'assistant',
|
||||
toolCallDeltas: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
argumentsDelta: '{"city":"Shanghai"}',
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
const payloads = parseSsePayloads(
|
||||
openAiChatStream.serializeEvent(normalized, context, createClaudeDownstreamContext()),
|
||||
);
|
||||
|
||||
expect(payloads[0]).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not replay historical tool identity on later multi-choice argument deltas', () => {
|
||||
const context = openAiChatStream.createContext('gpt-5');
|
||||
|
||||
const started = openAiChatStream.normalizeEvent({
|
||||
id: 'chatcmpl-stream-multi-tool-1',
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}, context, 'gpt-5');
|
||||
|
||||
openAiChatStream.serializeEvent(started, context, createClaudeDownstreamContext());
|
||||
|
||||
const continued = openAiChatStream.normalizeEvent({
|
||||
id: 'chatcmpl-stream-multi-tool-1',
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 1,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}, context, 'gpt-5');
|
||||
|
||||
const payloads = parseSsePayloads(
|
||||
openAiChatStream.serializeEvent(continued, context, createClaudeDownstreamContext()),
|
||||
);
|
||||
const toolCall = ((((payloads[0] as any).choices[0] as any).delta.tool_calls[0]) as Record<string, unknown>);
|
||||
|
||||
expect((payloads[0] as any).choices[0]).toMatchObject({
|
||||
index: 1,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
});
|
||||
expect(toolCall.id).toBeUndefined();
|
||||
expect(toolCall.type).toBeUndefined();
|
||||
expect((toolCall.function as Record<string, unknown>).name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
createClaudeDownstreamContext,
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
serializeNormalizedStreamEvent,
|
||||
serializeStreamDone,
|
||||
type ClaudeDownstreamContext,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatChoiceEvents, extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedStreamEvent } from './model.js';
|
||||
|
||||
const multiChoiceToolCallState = new WeakMap<
|
||||
StreamTransformContext,
|
||||
Record<string, { id?: string; name?: string; arguments?: string }>
|
||||
>();
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getMultiChoiceToolState(
|
||||
context: StreamTransformContext,
|
||||
): Record<string, { id?: string; name?: string; arguments?: string }> {
|
||||
const existing = multiChoiceToolCallState.get(context);
|
||||
if (existing) return existing;
|
||||
const created: Record<string, { id?: string; name?: string; arguments?: string }> = {};
|
||||
multiChoiceToolCallState.set(context, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function parseSerializedSse(lines: string[]): Array<{ index: number; payload: Record<string, unknown> }> {
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
if (!line.startsWith('data: ') || line.trim() === 'data: [DONE]') return null;
|
||||
try {
|
||||
return {
|
||||
index,
|
||||
payload: JSON.parse(line.slice(6)) as Record<string, unknown>,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item): item is { index: number; payload: Record<string, unknown> } => !!item);
|
||||
}
|
||||
|
||||
export const openAiChatStream = {
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
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 {
|
||||
...normalized,
|
||||
...(primaryChoice
|
||||
? {
|
||||
choiceIndex: primaryChoice.index,
|
||||
annotations: primaryChoice.annotations,
|
||||
citations: primaryChoice.citations,
|
||||
}
|
||||
: {}),
|
||||
...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
serializeEvent(
|
||||
event: OpenAiChatNormalizedStreamEvent,
|
||||
context: StreamTransformContext,
|
||||
downstreamContext?: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
if (
|
||||
Array.isArray(event.choiceEvents)
|
||||
&& event.choiceEvents.length > 0
|
||||
&& (event.choiceEvents.length > 1 || event.choiceEvents[0]?.index !== 0)
|
||||
) {
|
||||
const usagePayload = event.usagePayload
|
||||
? {
|
||||
...(isRecord(event.usagePayload) ? event.usagePayload : {}),
|
||||
...(event.usageDetails?.prompt_tokens_details
|
||||
? { prompt_tokens_details: event.usageDetails.prompt_tokens_details }
|
||||
: {}),
|
||||
...(event.usageDetails?.completion_tokens_details
|
||||
? { completion_tokens_details: event.usageDetails.completion_tokens_details }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
return [`data: ${JSON.stringify({
|
||||
id: context.id || `chatcmpl-${Date.now()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: context.created,
|
||||
model: context.model,
|
||||
choices: event.choiceEvents
|
||||
.map((choiceEvent) => {
|
||||
const delta: Record<string, unknown> = {};
|
||||
if (choiceEvent.role) delta.role = choiceEvent.role;
|
||||
if (choiceEvent.contentDelta !== undefined) delta.content = choiceEvent.contentDelta;
|
||||
if (choiceEvent.reasoningDelta) delta.reasoning_content = choiceEvent.reasoningDelta;
|
||||
if (Array.isArray(choiceEvent.toolCallDeltas) && choiceEvent.toolCallDeltas.length > 0) {
|
||||
const toolState = getMultiChoiceToolState(context);
|
||||
delta.tool_calls = choiceEvent.toolCallDeltas.map((toolCall) => {
|
||||
const toolIndex = Number.isFinite(toolCall.index) ? Math.max(0, Math.trunc(toolCall.index)) : 0;
|
||||
const stateKey = `${choiceEvent.index}:${toolIndex}`;
|
||||
const existing = toolState[stateKey] || {};
|
||||
toolState[stateKey] = {
|
||||
...(toolCall.id || existing.id ? { id: toolCall.id || existing.id } : {}),
|
||||
...(toolCall.name || existing.name ? { name: toolCall.name || existing.name } : {}),
|
||||
arguments: `${existing.arguments || ''}${toolCall.argumentsDelta ?? ''}`,
|
||||
};
|
||||
|
||||
const functionPayload: Record<string, unknown> = {};
|
||||
if (toolCall.name) functionPayload.name = toolCall.name;
|
||||
if (toolCall.argumentsDelta !== undefined) functionPayload.arguments = toolCall.argumentsDelta;
|
||||
|
||||
return {
|
||||
index: toolIndex,
|
||||
...(toolCall.id ? { id: toolCall.id } : {}),
|
||||
...(toolCall.id || toolCall.name ? { type: 'function' } : {}),
|
||||
...(Object.keys(functionPayload).length > 0 ? { function: functionPayload } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (Array.isArray(choiceEvent.annotations) && choiceEvent.annotations.length > 0) {
|
||||
delta.annotations = choiceEvent.annotations;
|
||||
}
|
||||
|
||||
return {
|
||||
index: choiceEvent.index,
|
||||
delta,
|
||||
finish_reason: choiceEvent.finishReason ?? null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.index - right.index),
|
||||
...(Array.isArray(event.citations) && event.citations.length > 0 ? { citations: event.citations } : {}),
|
||||
...(usagePayload && Object.keys(usagePayload).length > 0 ? { usage: usagePayload } : {}),
|
||||
})}\n\n`];
|
||||
}
|
||||
|
||||
const lines = serializeNormalizedStreamEvent(
|
||||
'openai',
|
||||
event,
|
||||
context,
|
||||
downstreamContext ?? createClaudeDownstreamContext(),
|
||||
);
|
||||
|
||||
if (
|
||||
(!Array.isArray(event.annotations) || event.annotations.length <= 0)
|
||||
&& (!Array.isArray(event.citations) || event.citations.length <= 0)
|
||||
&& !event.usagePayload
|
||||
&& !event.usageDetails
|
||||
) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const parsedEvents = parseSerializedSse(lines);
|
||||
if (parsedEvents.length <= 0) return lines;
|
||||
|
||||
for (const parsed of parsedEvents) {
|
||||
const payload = parsed.payload;
|
||||
if (Array.isArray(event.citations) && event.citations.length > 0) {
|
||||
payload.citations = event.citations;
|
||||
}
|
||||
|
||||
const firstChoice = Array.isArray(payload.choices) ? payload.choices[0] : null;
|
||||
if (Array.isArray(event.annotations) && event.annotations.length > 0 && isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
||||
firstChoice.delta.annotations = event.annotations;
|
||||
}
|
||||
|
||||
if (event.usagePayload || event.usageDetails) {
|
||||
payload.usage = {
|
||||
...(isRecord(event.usagePayload) ? event.usagePayload : {}),
|
||||
...(event.usageDetails?.prompt_tokens_details
|
||||
? { prompt_tokens_details: event.usageDetails.prompt_tokens_details }
|
||||
: {}),
|
||||
...(event.usageDetails?.completion_tokens_details
|
||||
? { completion_tokens_details: event.usageDetails.completion_tokens_details }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
lines[parsed.index] = `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
return lines;
|
||||
},
|
||||
serializeDone(
|
||||
context: StreamTransformContext,
|
||||
downstreamContext?: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
return serializeStreamDone(
|
||||
'openai',
|
||||
context,
|
||||
downstreamContext ?? createClaudeDownstreamContext(),
|
||||
);
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return pullSseEventsWithDone(buffer);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import type { OpenAiResponsesStreamEvent } from './stream.js';
|
||||
import type { OpenAiResponsesStreamEvent } from './streamBridge.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeCodexResponsesBodyForProxy } from './codexCompatibility.js';
|
||||
|
||||
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
|
||||
|
||||
describe('normalizeCodexResponsesBodyForProxy', () => {
|
||||
it('normalizes codex responses bodies before proxying upstream', () => {
|
||||
it('extracts codex system input into top-level instructions before proxying upstream', () => {
|
||||
const body = normalizeCodexResponsesBodyForProxy({
|
||||
input: [
|
||||
{
|
||||
@@ -10,7 +12,13 @@ describe('normalizeCodexResponsesBodyForProxy', () => {
|
||||
role: 'system',
|
||||
content: [{ type: 'input_text', text: 'be precise' }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
instructions: 'keep edits narrow',
|
||||
max_output_tokens: 512,
|
||||
max_completion_tokens: 256,
|
||||
max_tokens: 128,
|
||||
@@ -22,16 +30,28 @@ describe('normalizeCodexResponsesBodyForProxy', () => {
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: [{ type: 'input_text', text: 'be precise' }],
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
instructions: '',
|
||||
instructions: 'be precise\n\nkeep edits narrow',
|
||||
store: false,
|
||||
temperature: 0.3,
|
||||
});
|
||||
});
|
||||
|
||||
it('supplies a non-empty default instructions string for codex when missing', () => {
|
||||
const body = normalizeCodexResponsesBodyForProxy({
|
||||
input: 'hello',
|
||||
}, 'codex');
|
||||
|
||||
expect(body).toEqual({
|
||||
input: 'hello',
|
||||
instructions: CODEX_DEFAULT_INSTRUCTIONS,
|
||||
store: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-codex bodies untouched', () => {
|
||||
const source = {
|
||||
input: 'hello',
|
||||
|
||||
@@ -6,15 +6,76 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
|
||||
|
||||
function extractTextFromContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item) => extractTextFromContent(item))
|
||||
.filter((item) => item.length > 0)
|
||||
.join('');
|
||||
}
|
||||
if (!isRecord(content)) return '';
|
||||
if (typeof content.text === 'string') return content.text;
|
||||
if (typeof content.content === 'string') return content.content;
|
||||
if (typeof content.output_text === 'string') return content.output_text;
|
||||
if (typeof content.input_text === 'string') return content.input_text;
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractSystemMessagesToInstructions(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!Array.isArray(body.input) || body.input.length <= 0) return body;
|
||||
|
||||
const extractedSystemTexts: string[] = [];
|
||||
const remainingInput: unknown[] = [];
|
||||
|
||||
for (const item of body.input) {
|
||||
if (!isRecord(item)) {
|
||||
remainingInput.push(item);
|
||||
continue;
|
||||
}
|
||||
if (asTrimmedString(item.type).toLowerCase() !== 'message') {
|
||||
remainingInput.push(item);
|
||||
continue;
|
||||
}
|
||||
if (asTrimmedString(item.role).toLowerCase() !== 'system') {
|
||||
remainingInput.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = extractTextFromContent(item.content);
|
||||
if (text.trim()) {
|
||||
extractedSystemTexts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
if (extractedSystemTexts.length <= 0) return body;
|
||||
|
||||
const extractedInstructions = extractedSystemTexts.join('\n\n');
|
||||
const existingInstructions = typeof body.instructions === 'string' ? body.instructions : '';
|
||||
const nextInstructions = existingInstructions.trim()
|
||||
? `${extractedInstructions}\n\n${existingInstructions}`
|
||||
: extractedInstructions;
|
||||
|
||||
return {
|
||||
...body,
|
||||
input: remainingInput,
|
||||
instructions: nextInstructions,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureCodexResponsesInstructions(
|
||||
body: Record<string, unknown>,
|
||||
sitePlatform: string,
|
||||
): Record<string, unknown> {
|
||||
if (sitePlatform !== 'codex') return body;
|
||||
if (typeof body.instructions === 'string') return body;
|
||||
if (typeof body.instructions === 'string' && body.instructions.trim()) return body;
|
||||
return {
|
||||
...body,
|
||||
instructions: '',
|
||||
instructions: CODEX_DEFAULT_INSTRUCTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,35 +102,12 @@ function stripCodexUnsupportedResponsesFields(
|
||||
return next;
|
||||
}
|
||||
|
||||
function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
|
||||
if (!Array.isArray(input)) return input;
|
||||
return input.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
if (asTrimmedString(item.type).toLowerCase() !== 'message') return item;
|
||||
if (asTrimmedString(item.role).toLowerCase() !== 'system') return item;
|
||||
return {
|
||||
...item,
|
||||
role: 'developer',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyCodexResponsesCompatibility(
|
||||
body: Record<string, unknown>,
|
||||
sitePlatform: string,
|
||||
): Record<string, unknown> {
|
||||
if (sitePlatform !== 'codex') return body;
|
||||
|
||||
const next: Record<string, unknown> = {
|
||||
...body,
|
||||
input: convertCodexSystemRoleToDeveloper(body.input),
|
||||
};
|
||||
|
||||
if (typeof next.instructions !== 'string') {
|
||||
next.instructions = '';
|
||||
}
|
||||
|
||||
return next;
|
||||
return extractSystemMessagesToInstructions(body);
|
||||
}
|
||||
|
||||
export function normalizeCodexResponsesBodyForProxy(
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
} from './conversion.js';
|
||||
import {
|
||||
hasEndpointMismatchHint,
|
||||
inferRequiredEndpointFromProtocolError,
|
||||
} from '../../shared/endpointCompatibility.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
@@ -18,11 +22,17 @@ function asTrimmedString(value: unknown): string {
|
||||
|
||||
export function buildResponsesCompatibilityBodies(
|
||||
body: Record<string, unknown>,
|
||||
options?: {
|
||||
sitePlatform?: string;
|
||||
},
|
||||
): Record<string, unknown>[] {
|
||||
const candidates: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
const useSemanticDedupe = asTrimmedString(options?.sitePlatform).toLowerCase() === 'sub2api';
|
||||
try {
|
||||
const originalKey = JSON.stringify(body);
|
||||
const originalKey = useSemanticDedupe
|
||||
? fingerprintCompatibilityValue(body)
|
||||
: JSON.stringify(body);
|
||||
if (originalKey) seen.add(originalKey);
|
||||
} catch {
|
||||
// ignore non-serializable bodies
|
||||
@@ -32,7 +42,9 @@ export function buildResponsesCompatibilityBodies(
|
||||
if (!next) return;
|
||||
let key = '';
|
||||
try {
|
||||
key = JSON.stringify(next);
|
||||
key = useSemanticDedupe
|
||||
? fingerprintCompatibilityValue(next)
|
||||
: JSON.stringify(next);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -77,19 +89,28 @@ export function buildResponsesCompatibilityBodies(
|
||||
'background',
|
||||
'top_logprobs',
|
||||
] as const;
|
||||
const normalizedSitePlatform = asTrimmedString(options?.sitePlatform).toLowerCase();
|
||||
if (normalizedSitePlatform === 'sub2api' && body.store !== undefined) {
|
||||
richCandidate.store = cloneJsonValue(body.store);
|
||||
}
|
||||
for (const key of passthroughFields) {
|
||||
if (body[key] === undefined) continue;
|
||||
richCandidate[key] = cloneJsonValue(body[key]);
|
||||
}
|
||||
push(richCandidate);
|
||||
}
|
||||
push(buildStrictResponsesBody(body));
|
||||
if (!shouldPreserveResponsesCompatibilitySemantics(options?.sitePlatform, body)) {
|
||||
push(buildStrictResponsesBody(body));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function buildResponsesCompatibilityHeaderCandidates(
|
||||
headers: Record<string, string>,
|
||||
stream: boolean,
|
||||
options?: {
|
||||
sitePlatform?: string;
|
||||
},
|
||||
): Record<string, string>[] {
|
||||
const candidates: Record<string, string>[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -106,6 +127,12 @@ export function buildResponsesCompatibilityHeaderCandidates(
|
||||
|
||||
push(headers);
|
||||
|
||||
const normalizedSitePlatform = asTrimmedString(options?.sitePlatform).toLowerCase();
|
||||
if (normalizedSitePlatform === 'sub2api') {
|
||||
push(buildSub2ApiResponsesCompatibilityHeaders(headers, stream));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const minimal: Record<string, string> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const key = rawKey.toLowerCase();
|
||||
@@ -129,6 +156,7 @@ export function shouldRetryResponsesCompatibility(input: {
|
||||
endpoint: string;
|
||||
status: number;
|
||||
rawErrText: string;
|
||||
body: Record<string, unknown>;
|
||||
}): boolean {
|
||||
if (input.endpoint !== 'responses') return false;
|
||||
if (input.status !== 400) return false;
|
||||
@@ -138,6 +166,7 @@ export function shouldRetryResponsesCompatibility(input: {
|
||||
const message = parsedError.message.trim().toLowerCase();
|
||||
const compact = `${type} ${code} ${message}`.trim();
|
||||
const rawCompact = (input.rawErrText || '').toLowerCase();
|
||||
const requiredEndpoint = inferRequiredEndpointFromProtocolError(input.rawErrText);
|
||||
|
||||
if (
|
||||
compact.includes('invalid_api_key')
|
||||
@@ -150,11 +179,30 @@ export function shouldRetryResponsesCompatibility(input: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === 'upstream_error' || code === 'upstream_error') return true;
|
||||
if (message === 'upstream_error' || message === 'upstream request failed') return true;
|
||||
if (rawCompact.includes('upstream_error')) return true;
|
||||
const hasCompatibilityHint = (
|
||||
compact.includes('unsupported')
|
||||
|| compact.includes('not supported')
|
||||
|| compact.includes('application/json')
|
||||
|| compact.includes('content-type')
|
||||
|| compact.includes('request validation failed')
|
||||
|| requiredEndpoint !== null
|
||||
|| hasEndpointMismatchHint(input.rawErrText)
|
||||
|| compact.includes('invalid_request_error')
|
||||
|| compact.includes('bad_response_status_code')
|
||||
|| compact.includes('openai_error')
|
||||
|| compact.includes('unsupported legacy protocol')
|
||||
);
|
||||
if (hasCompatibilityHint) return true;
|
||||
|
||||
return true;
|
||||
const compatibilityCandidates = buildResponsesCompatibilityBodies(input.body);
|
||||
if (compatibilityCandidates.length > 0) {
|
||||
if (type === 'upstream_error' || code === 'upstream_error') return true;
|
||||
if (message === 'upstream_error' || message === 'upstream request failed') return true;
|
||||
if (rawCompact.includes('upstream_error')) return true;
|
||||
if (compact.length > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldDowngradeResponsesChatToMessages(
|
||||
@@ -164,7 +212,7 @@ export function shouldDowngradeResponsesChatToMessages(
|
||||
): boolean {
|
||||
if (!endpointPath.includes('/chat/completions')) return false;
|
||||
if (status < 400 || status >= 500) return false;
|
||||
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
||||
return inferRequiredEndpointFromProtocolError(upstreamErrorText) === 'messages';
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
@@ -183,6 +231,20 @@ function cloneJsonValue<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
function fingerprintCompatibilityValue(value: unknown): string {
|
||||
if (value === undefined) return '__undefined__';
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => fingerprintCompatibilityValue(item)).join(',')}]`;
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${fingerprintCompatibilityValue(item)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
function parseUpstreamErrorShape(rawText: string): {
|
||||
type: string;
|
||||
code: string;
|
||||
@@ -208,6 +270,60 @@ function getExplicitResponsesInstructions(body: Record<string, unknown>): string
|
||||
return typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||
}
|
||||
|
||||
function shouldPreserveResponsesCompatibilitySemantics(
|
||||
sitePlatform: string | undefined,
|
||||
body: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (asTrimmedString(sitePlatform).toLowerCase() !== 'sub2api') return false;
|
||||
return [
|
||||
'store',
|
||||
'include',
|
||||
'reasoning',
|
||||
'previous_response_id',
|
||||
'truncation',
|
||||
'text',
|
||||
'service_tier',
|
||||
'safety_identifier',
|
||||
'max_tool_calls',
|
||||
'prompt_cache_key',
|
||||
'prompt_cache_retention',
|
||||
'background',
|
||||
'top_logprobs',
|
||||
].some((key) => body[key] !== undefined);
|
||||
}
|
||||
|
||||
const SUB2API_RESPONSES_COMPATIBILITY_HEADER_ALLOWLIST = new Set([
|
||||
'accept',
|
||||
'accept-language',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'conversation-id',
|
||||
'conversation_id',
|
||||
'openai-beta',
|
||||
'originator',
|
||||
'session-id',
|
||||
'session_id',
|
||||
'user-agent',
|
||||
'x-api-key',
|
||||
'x-codex-turn-metadata',
|
||||
'x-codex-turn-state',
|
||||
]);
|
||||
|
||||
function buildSub2ApiResponsesCompatibilityHeaders(
|
||||
headers: Record<string, string>,
|
||||
stream: boolean,
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const key = rawKey.toLowerCase();
|
||||
if (!SUB2API_RESPONSES_COMPATIBILITY_HEADER_ALLOWLIST.has(key)) continue;
|
||||
next[key] = rawValue;
|
||||
}
|
||||
if (!next['content-type']) next['content-type'] = 'application/json';
|
||||
if (stream && !next.accept) next.accept = 'text/event-stream';
|
||||
return next;
|
||||
}
|
||||
|
||||
function stripResponsesMetadata(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from './conversion.js';
|
||||
import {
|
||||
buildResponsesCompatibilityBodies,
|
||||
buildResponsesCompatibilityHeaderCandidates,
|
||||
convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaCompatibility,
|
||||
normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility,
|
||||
normalizeResponsesMessageContent as normalizeResponsesMessageContentViaCompatibility,
|
||||
sanitizeResponsesBodyForProxy as sanitizeResponsesBodyForProxyViaCompatibility,
|
||||
shouldRetryResponsesCompatibility,
|
||||
} from './compatibility.js';
|
||||
import {
|
||||
normalizeResponsesInputForCompatibility,
|
||||
@@ -704,6 +706,77 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
expect(result.include).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not synthesize max_output_tokens when chat-style input omits all token-limit fields', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.max_output_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clamps explicit chat token limits to the sub2api floor when converting to Responses bodies', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 64,
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.max_output_tokens).toBe(128);
|
||||
});
|
||||
|
||||
it('uses sub2api-compatible placeholders for empty tool arguments and empty tool outputs', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_empty_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
arguments: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_empty_1',
|
||||
content: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.input).toEqual([
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_empty_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{}',
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_empty_1',
|
||||
output: '(empty)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps OpenAI file-style content blocks into inline-only Responses input_file blocks', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
@@ -1568,6 +1641,87 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not generate extra compatibility bodies for sub2api when responses-only semantics are already present', () => {
|
||||
const candidates = buildResponsesCompatibilityBodies({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
stream: true,
|
||||
store: false,
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
previous_response_id: 'resp_prev_9',
|
||||
prompt_cache_key: 'cache-key-9',
|
||||
service_tier: 'priority',
|
||||
background: true,
|
||||
}, {
|
||||
sitePlatform: 'sub2api',
|
||||
});
|
||||
|
||||
expect(candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds a sub2api-safe compatibility header candidate that preserves session and codex passthrough headers', () => {
|
||||
const candidates = buildResponsesCompatibilityHeaderCandidates({
|
||||
accept: 'text/event-stream',
|
||||
authorization: 'Bearer sk-test',
|
||||
'content-type': 'application/json',
|
||||
'accept-language': 'zh-CN',
|
||||
'openai-beta': 'responses-2025-03-11',
|
||||
originator: 'codex_cli_rs',
|
||||
'user-agent': 'client-ua/1.0',
|
||||
session_id: 'session-123',
|
||||
conversation_id: 'conversation-123',
|
||||
'x-codex-turn-state': 'turn-state',
|
||||
'x-codex-turn-metadata': 'turn-metadata',
|
||||
'x-stainless-lang': 'typescript',
|
||||
version: '0.202.0',
|
||||
}, true, {
|
||||
sitePlatform: 'sub2api',
|
||||
});
|
||||
|
||||
expect(candidates).toContainEqual({
|
||||
accept: 'text/event-stream',
|
||||
authorization: 'Bearer sk-test',
|
||||
'content-type': 'application/json',
|
||||
'accept-language': 'zh-CN',
|
||||
'openai-beta': 'responses-2025-03-11',
|
||||
originator: 'codex_cli_rs',
|
||||
'user-agent': 'client-ua/1.0',
|
||||
session_id: 'session-123',
|
||||
conversation_id: 'conversation-123',
|
||||
'x-codex-turn-state': 'turn-state',
|
||||
'x-codex-turn-metadata': 'turn-metadata',
|
||||
});
|
||||
expect(candidates).not.toContainEqual({
|
||||
accept: 'text/event-stream',
|
||||
authorization: 'Bearer sk-test',
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates semantically identical sub2api retry bodies even when field order differs', () => {
|
||||
const candidates = buildResponsesCompatibilityBodies({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
previous_response_id: 'resp_prev_9',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
prompt_cache_key: 'cache-key-9',
|
||||
service_tier: 'priority',
|
||||
background: true,
|
||||
stream: true,
|
||||
store: false,
|
||||
}, {
|
||||
sitePlatform: 'sub2api',
|
||||
});
|
||||
|
||||
expect(candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves tools and tool_choice across all compatibility retry bodies', () => {
|
||||
const candidates = buildResponsesCompatibilityBodies({
|
||||
model: 'gpt-5',
|
||||
@@ -1618,6 +1772,39 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not retry auth-shaped failures and still retries generic upstream errors through compatibility variants', () => {
|
||||
expect(shouldRetryResponsesCompatibility({
|
||||
endpoint: 'responses',
|
||||
status: 400,
|
||||
rawErrText: JSON.stringify({
|
||||
error: {
|
||||
message: 'invalid_api_key',
|
||||
type: 'authentication_error',
|
||||
},
|
||||
}),
|
||||
body: {
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
},
|
||||
})).toBe(false);
|
||||
|
||||
expect(shouldRetryResponsesCompatibility({
|
||||
endpoint: 'responses',
|
||||
status: 400,
|
||||
rawErrText: JSON.stringify({
|
||||
error: {
|
||||
message: '',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
}),
|
||||
body: {
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
metadata: { trace_id: 'req-1' },
|
||||
},
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('maps native tool choices and strict function tools into OpenAI-compatible fallback bodies', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
|
||||
@@ -439,6 +439,8 @@ const RESPONSES_COMPATIBILITY_FILTER_FIELDS = new Set([
|
||||
'verbosity',
|
||||
]);
|
||||
|
||||
const MIN_RESPONSES_MAX_OUTPUT_TOKENS = 128;
|
||||
|
||||
export function sanitizeResponsesBodyForProxy(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
@@ -571,7 +573,7 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
type: 'function_call',
|
||||
call_id: callId,
|
||||
name: getShortToolName(name, toolNameMap),
|
||||
arguments: argumentsValue,
|
||||
arguments: argumentsValue || '{}',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -580,10 +582,16 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
if (role === 'tool') {
|
||||
const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
|
||||
if (!callId) continue;
|
||||
const output = normalizeToolOutput(item.content);
|
||||
inputItems.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: normalizeToolOutput(item.content),
|
||||
output: (
|
||||
(typeof output === 'string' && output === '')
|
||||
|| (Array.isArray(output) && output.length === 0)
|
||||
)
|
||||
? '(empty)'
|
||||
: output,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -597,20 +605,25 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
});
|
||||
}
|
||||
|
||||
const maxOutputTokens = (
|
||||
const requestedMaxOutputTokens = (
|
||||
toFiniteNumber(openaiBody.max_output_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_completion_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_tokens)
|
||||
?? 4096
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
max_output_tokens: maxOutputTokens,
|
||||
input: inputItems,
|
||||
};
|
||||
|
||||
if (requestedMaxOutputTokens !== null && requestedMaxOutputTokens > 0) {
|
||||
body.max_output_tokens = Math.max(
|
||||
MIN_RESPONSES_MAX_OUTPUT_TOKENS,
|
||||
Math.trunc(requestedMaxOutputTokens),
|
||||
);
|
||||
}
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.instructions = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
@@ -72,6 +72,41 @@ describe('openAiResponsesTransformer.inbound', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips continuation turnState through responses metadata bridging', () => {
|
||||
const built = openAiResponsesTransformer.buildProtocolRequest({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
turnState: 'turn-state-responses-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(built).toMatchObject({
|
||||
metadata: {
|
||||
metapi_turn_state: 'turn-state-responses-1',
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = openAiResponsesTransformer.parseRequest({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
metadata: {
|
||||
metapi_turn_state: 'turn-state-responses-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.value).toMatchObject({
|
||||
continuation: {
|
||||
turnState: 'turn-state-responses-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a protocol request envelope with a normalized responses body', () => {
|
||||
const result = openAiResponsesTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
@@ -23,11 +22,19 @@ import {
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
} from './aggregator.js';
|
||||
import { openAiResponsesOutbound } from './outbound.js';
|
||||
import { openAiResponsesInbound } from './inbound.js';
|
||||
import { createResponsesProxyStreamSession } from './proxyStream.js';
|
||||
import {
|
||||
buildCanonicalRequestToOpenAiResponsesBody,
|
||||
parseOpenAiResponsesRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
import {
|
||||
openAiResponsesResponseBridge,
|
||||
buildNormalizedFinalToOpenAiResponsesPayload,
|
||||
normalizeOpenAiResponsesFinalToNormalized,
|
||||
} from './responseBridge.js';
|
||||
import { createResponsesEndpointStrategy } from './routeCompatibility.js';
|
||||
import { openAiResponsesStream } from './stream.js';
|
||||
import { openAiResponsesStream } from './streamBridge.js';
|
||||
import { openAiResponsesUsage } from './usage.js';
|
||||
import type {
|
||||
OpenAiResponsesParsedRequest as OpenAiResponsesParsedRequestModel,
|
||||
@@ -45,7 +52,7 @@ export const openAiResponsesTransformer = {
|
||||
fromOpenAiBody: convertOpenAiBodyToResponsesBody,
|
||||
toOpenAiBody: convertResponsesBodyToOpenAiBody,
|
||||
},
|
||||
outbound: openAiResponsesOutbound,
|
||||
outbound: openAiResponsesResponseBridge,
|
||||
stream: openAiResponsesStream,
|
||||
usage: openAiResponsesUsage,
|
||||
compatibility: {
|
||||
@@ -68,59 +75,13 @@ export const openAiResponsesTransformer = {
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = openAiResponsesInbound.parse(body, {
|
||||
defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude,
|
||||
});
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid responses request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const responsesBody = parsed.value.parsed.normalizedBody;
|
||||
const openAiBody = convertResponsesBodyToOpenAiBody(
|
||||
responsesBody,
|
||||
typeof responsesBody.model === 'string' ? responsesBody.model : parsed.value.model,
|
||||
responsesBody.stream === true,
|
||||
{ defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude },
|
||||
);
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'openai-responses',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
return parseOpenAiResponsesRequestToCanonical(body, ctx);
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
if (request.reasoning) {
|
||||
openAiBody.reasoning = {
|
||||
...(request.reasoning.effort ? { effort: request.reasoning.effort } : {}),
|
||||
...(request.reasoning.budgetTokens !== undefined ? { budget_tokens: request.reasoning.budgetTokens } : {}),
|
||||
...(request.reasoning.summary ? { summary: request.reasoning.summary } : {}),
|
||||
};
|
||||
}
|
||||
return convertOpenAiBodyToResponsesBody(openAiBody, request.requestedModel, request.stream);
|
||||
return buildCanonicalRequestToOpenAiResponsesBody(request);
|
||||
},
|
||||
transformRequest(
|
||||
body: unknown,
|
||||
@@ -132,7 +93,7 @@ export const openAiResponsesTransformer = {
|
||||
return openAiResponsesStream.createContext(modelName);
|
||||
},
|
||||
transformFinalResponse(payload: unknown, modelName: string, fallbackText = '') {
|
||||
return openAiResponsesOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
return normalizeOpenAiResponsesFinalToNormalized(payload, modelName, fallbackText);
|
||||
},
|
||||
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string) {
|
||||
return openAiResponsesStream.normalizeEvent(payload, context, modelName);
|
||||
@@ -140,6 +101,15 @@ export const openAiResponsesTransformer = {
|
||||
pullSseEvents(buffer: string) {
|
||||
return openAiResponsesStream.pullSseEvents(buffer);
|
||||
},
|
||||
serializeFinalResponse(
|
||||
normalized: Parameters<typeof buildNormalizedFinalToOpenAiResponsesPayload>[0]['normalized'],
|
||||
input: Omit<Parameters<typeof buildNormalizedFinalToOpenAiResponsesPayload>[0], 'normalized'>,
|
||||
) {
|
||||
return buildNormalizedFinalToOpenAiResponsesPayload({
|
||||
...input,
|
||||
normalized,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer;
|
||||
|
||||
@@ -1,449 +1,12 @@
|
||||
import {
|
||||
normalizeUpstreamFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js';
|
||||
import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJson(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJson(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ensureResponseId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureMessageId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `msg_${Date.now()}`;
|
||||
return trimmed.startsWith('msg_') ? trimmed : `msg_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureFunctionCallId(rawId: string): string {
|
||||
const trimmed = rawId.trim();
|
||||
if (!trimmed) return `call_${Date.now()}`;
|
||||
return trimmed.startsWith('call_') ? trimmed : `call_${trimmed}`;
|
||||
}
|
||||
|
||||
function stringifyToolInput(input: unknown): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
try {
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export type ResponsesUsageSummary = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
inputTokensDetails?: Record<string, unknown>;
|
||||
outputTokensDetails?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ResponsesToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
export type ResponsesFinalSerializationMode = 'response' | 'compact';
|
||||
|
||||
type ResponsesOutputItem = Record<string, unknown>;
|
||||
|
||||
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
if (Array.isArray(payload.choices)) {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord((choice as any)?.message) ? (choice as any).message : {};
|
||||
const toolCalls = Array.isArray((message as any).tool_calls) ? (message as any).tool_calls : [];
|
||||
return toolCalls
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const fn = isRecord(item.function) ? item.function : {};
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof fn.name === 'string' ? fn.name : '';
|
||||
const args = typeof fn.arguments === 'string' ? fn.arguments : '';
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
if (payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
return payload.content
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item) || item.type !== 'tool_use') return null;
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const args = stringifyToolInput(item.input);
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractToolCallsFromNormalized(normalized: NormalizedFinalResponse): ResponsesToolCall[] {
|
||||
return Array.isArray(normalized.toolCalls)
|
||||
? normalized.toolCalls
|
||||
.map((item) => {
|
||||
const id = ensureFunctionCallId(asTrimmedString(item.id));
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
arguments: typeof item.arguments === 'string' ? item.arguments : '',
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item)
|
||||
: [];
|
||||
}
|
||||
|
||||
function extractAnnotationsFromUpstream(payload: unknown): unknown[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
if (Array.isArray(payload.choices)) {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord((choice as any)?.message) ? (choice as any).message : {};
|
||||
const annotations = Array.isArray((message as any).annotations) ? (message as any).annotations : [];
|
||||
return annotations.map((item) => cloneJson(item));
|
||||
}
|
||||
|
||||
if (payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
const annotations = payload.content
|
||||
.filter((item): item is Record<string, unknown> => isRecord(item))
|
||||
.flatMap((item) => (Array.isArray(item.annotations) ? item.annotations : []));
|
||||
return annotations.map((item) => cloneJson(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOutputItem[] {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.output)) return [];
|
||||
|
||||
return payload.output
|
||||
.map((rawItem): ResponsesOutputItem | null => {
|
||||
if (!isRecord(rawItem)) return null;
|
||||
const itemType = asTrimmedString(rawItem.type).toLowerCase();
|
||||
if (!itemType) return null;
|
||||
|
||||
if (itemType === 'message') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'message',
|
||||
role: asTrimmedString(rawItem.role) || 'assistant',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
content: Array.isArray(rawItem.content) ? cloneJson(rawItem.content) : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'reasoning') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'reasoning',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
encrypted_content: rawItem.encrypted_content,
|
||||
summary: Array.isArray(rawItem.summary) ? cloneJson(rawItem.summary) : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'function_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''),
|
||||
type: 'function_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)),
|
||||
name: asTrimmedString(rawItem.name),
|
||||
arguments: typeof rawItem.arguments === 'string' ? rawItem.arguments : '',
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'custom_tool_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''),
|
||||
type: 'custom_tool_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)),
|
||||
name: asTrimmedString(rawItem.name),
|
||||
input: typeof rawItem.input === 'string' ? rawItem.input : '',
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: itemType,
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id),
|
||||
output: cloneJson(rawItem.output),
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'image_generation_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'image_generation_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
result: cloneJson(rawItem.result),
|
||||
partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [],
|
||||
...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}),
|
||||
...(rawItem.mime_type !== undefined ? { mime_type: cloneJson(rawItem.mime_type) } : {}),
|
||||
...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}),
|
||||
...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}),
|
||||
...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}),
|
||||
...(rawItem.revised_prompt !== undefined ? { revised_prompt: cloneJson(rawItem.revised_prompt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType.startsWith('mcp_')) {
|
||||
return cloneJson(rawItem);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is ResponsesOutputItem => item !== null);
|
||||
}
|
||||
|
||||
function collectOutputTextFromItems(items: ResponsesOutputItem[]): string {
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) continue;
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
if ((partType === 'output_text' || partType === 'text') && typeof part.text === 'string') {
|
||||
textParts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('');
|
||||
}
|
||||
|
||||
function extractSyntheticUsageDetails(upstreamPayload: unknown): {
|
||||
inputTokensDetails?: Record<string, unknown>;
|
||||
outputTokensDetails?: Record<string, unknown>;
|
||||
} {
|
||||
if (!isRecord(upstreamPayload) || !isRecord(upstreamPayload.usage)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const usage = upstreamPayload.usage;
|
||||
const promptDetails = (
|
||||
(isRecord(usage.input_tokens_details) ? usage.input_tokens_details : null)
|
||||
?? (isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : null)
|
||||
);
|
||||
const completionDetails = (
|
||||
(isRecord(usage.output_tokens_details) ? usage.output_tokens_details : null)
|
||||
?? (isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : null)
|
||||
);
|
||||
|
||||
return {
|
||||
...(promptDetails ? { inputTokensDetails: cloneJson(promptDetails) } : {}),
|
||||
...(completionDetails ? { outputTokensDetails: cloneJson(completionDetails) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResponsesFinalPayload(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText = '',
|
||||
): NormalizedFinalResponse {
|
||||
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
|
||||
}
|
||||
|
||||
function buildResponsesUsagePayload(
|
||||
usage: ResponsesUsageSummary,
|
||||
syntheticUsageDetails: {
|
||||
inputTokensDetails?: Record<string, unknown>;
|
||||
outputTokensDetails?: Record<string, unknown>;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails
|
||||
? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) }
|
||||
: {}),
|
||||
...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails
|
||||
? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeResponsesFinalPayload(input: {
|
||||
upstreamPayload: unknown;
|
||||
normalized: NormalizedFinalResponse;
|
||||
usage: ResponsesUsageSummary;
|
||||
serializationMode?: ResponsesFinalSerializationMode;
|
||||
}): Record<string, unknown> {
|
||||
const {
|
||||
upstreamPayload,
|
||||
normalized,
|
||||
usage,
|
||||
serializationMode = 'response',
|
||||
} = input;
|
||||
if (isRecord(upstreamPayload)) {
|
||||
if (upstreamPayload.object === 'response.compaction') {
|
||||
return upstreamPayload;
|
||||
}
|
||||
if (serializationMode === 'response' && upstreamPayload.object === 'response') {
|
||||
return upstreamPayload;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedId = typeof normalized.id === 'string' && normalized.id.trim()
|
||||
? normalized.id.trim()
|
||||
: `resp_${Date.now()}`;
|
||||
const responseId = ensureResponseId(normalizedId);
|
||||
const messageId = ensureMessageId(normalizedId);
|
||||
const syntheticOutput = extractSyntheticOutputItemsFromUpstream(upstreamPayload);
|
||||
const syntheticUsageDetails = extractSyntheticUsageDetails(upstreamPayload);
|
||||
const toolCalls = (() => {
|
||||
const extracted = extractToolCallsFromUpstream(upstreamPayload);
|
||||
if (extracted.length > 0) return extracted;
|
||||
return extractToolCallsFromNormalized(normalized);
|
||||
})();
|
||||
const rawReasoningSignature = typeof normalized.reasoningSignature === 'string'
|
||||
? normalized.reasoningSignature.trim()
|
||||
: '';
|
||||
const encryptedReasoning = decodeOpenAiEncryptedReasoning(rawReasoningSignature)
|
||||
?? (rawReasoningSignature && !rawReasoningSignature.startsWith('metapi:') ? rawReasoningSignature : null);
|
||||
const annotations = extractAnnotationsFromUpstream(upstreamPayload);
|
||||
|
||||
const output: Array<Record<string, unknown>> = syntheticOutput.map((item) => cloneJson(item));
|
||||
const hasReasoningItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'reasoning');
|
||||
const hasMessageItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'message');
|
||||
const hasToolLikeItem = output.some((item) => {
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
return (
|
||||
itemType === 'function_call'
|
||||
|| itemType === 'custom_tool_call'
|
||||
|| itemType === 'function_call_output'
|
||||
|| itemType === 'custom_tool_call_output'
|
||||
|| itemType === 'image_generation_call'
|
||||
);
|
||||
});
|
||||
|
||||
if ((normalized.reasoningContent || encryptedReasoning) && !hasReasoningItem) {
|
||||
const reasoningItem: Record<string, unknown> = {
|
||||
id: ensureMessageId(`${normalizedId}_reasoning`),
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: normalized.reasoningContent
|
||||
? [{
|
||||
type: 'summary_text',
|
||||
text: normalized.reasoningContent,
|
||||
}]
|
||||
: [],
|
||||
};
|
||||
if (encryptedReasoning) {
|
||||
reasoningItem.encrypted_content = encryptedReasoning;
|
||||
}
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
|
||||
if ((normalized.content || (!hasToolLikeItem && output.length === 0 && toolCalls.length === 0)) && !hasMessageItem) {
|
||||
const textPart: Record<string, unknown> = {
|
||||
type: 'output_text',
|
||||
text: normalized.content || '',
|
||||
};
|
||||
if (annotations.length > 0) {
|
||||
textPart.annotations = annotations;
|
||||
}
|
||||
output.push({
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [textPart],
|
||||
});
|
||||
}
|
||||
|
||||
if (toolCalls.length > 0 && !hasToolLikeItem) {
|
||||
for (const toolCall of toolCalls) {
|
||||
const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments);
|
||||
if (mcpItem) {
|
||||
output.push(mcpItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
id: toolCall.id,
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const usagePayload = buildResponsesUsagePayload(usage, syntheticUsageDetails);
|
||||
if (serializationMode === 'compact') {
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'response.compaction',
|
||||
created_at: normalized.created,
|
||||
output,
|
||||
usage: usagePayload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created_at: normalized.created,
|
||||
status: 'completed',
|
||||
model: normalized.model,
|
||||
output,
|
||||
output_text: normalized.content || collectOutputTextFromItems(output),
|
||||
usage: usagePayload,
|
||||
};
|
||||
}
|
||||
|
||||
export const openAiResponsesOutbound = {
|
||||
normalizeFinal: normalizeResponsesFinalPayload,
|
||||
serializeFinal: serializeResponsesFinalPayload,
|
||||
};
|
||||
|
||||
export type {
|
||||
ResponsesFinalSerializationMode,
|
||||
ResponsesToolCall,
|
||||
ResponsesUsageSummary,
|
||||
} from './responseBridge.js';
|
||||
export {
|
||||
serializeResponsesFinalPayload as toResponsesPayload,
|
||||
};
|
||||
openAiResponsesOutbound,
|
||||
openAiResponsesResponseBridge,
|
||||
buildNormalizedFinalToOpenAiResponsesPayload as serializeResponsesFinalPayload,
|
||||
buildNormalizedFinalToOpenAiResponsesPayload as toResponsesPayload,
|
||||
normalizeOpenAiResponsesFinalToNormalized as normalizeResponsesFinalPayload,
|
||||
} from './responseBridge.js';
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
|
||||
import { type ParsedSseEvent } from '../../shared/normalized.js';
|
||||
import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js';
|
||||
import { openAiResponsesOutbound } from './outbound.js';
|
||||
import { openAiResponsesStream } from './stream.js';
|
||||
import {
|
||||
hasMeaningfulResponsesOutputItem,
|
||||
hasMeaningfulResponsesPayloadOutput,
|
||||
openAiResponsesStream,
|
||||
preserveMeaningfulResponsesTerminalPayload,
|
||||
serializeResponsesUpstreamFinalAsStream,
|
||||
} from './streamBridge.js';
|
||||
import { config } from '../../../config.js';
|
||||
|
||||
type StreamReader = {
|
||||
@@ -45,42 +50,8 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasMeaningfulContentPart(part: unknown): boolean {
|
||||
if (!isRecord(part)) return false;
|
||||
const partType = typeof part.type === 'string' ? part.type.trim().toLowerCase() : '';
|
||||
if (partType === 'output_text' || partType === 'text') {
|
||||
return hasNonEmptyString(part.text);
|
||||
}
|
||||
return partType.length > 0;
|
||||
}
|
||||
|
||||
function hasMeaningfulOutputItem(item: unknown): boolean {
|
||||
if (!isRecord(item)) return false;
|
||||
const itemType = typeof item.type === 'string' ? item.type.trim().toLowerCase() : '';
|
||||
if (itemType === 'message') {
|
||||
return Array.isArray(item.content) && item.content.some((part) => hasMeaningfulContentPart(part));
|
||||
}
|
||||
if (itemType === 'reasoning') {
|
||||
return (
|
||||
(Array.isArray(item.summary) && item.summary.some((part) => hasMeaningfulContentPart(part)))
|
||||
|| hasNonEmptyString(item.encrypted_content)
|
||||
);
|
||||
}
|
||||
return itemType.length > 0;
|
||||
}
|
||||
|
||||
function hasMeaningfulResponsesPayloadOutput(payload: unknown): boolean {
|
||||
if (!isRecord(payload)) return false;
|
||||
if (hasNonEmptyString(payload.output_text)) return true;
|
||||
return Array.isArray(payload.output) && payload.output.some((item) => hasMeaningfulOutputItem(item));
|
||||
}
|
||||
|
||||
function hasMeaningfulAggregateOutput(state: ReturnType<typeof createOpenAiResponsesAggregateState>): boolean {
|
||||
return state.outputItems.some((item) => hasMeaningfulOutputItem(item));
|
||||
return state.outputItems.some((item) => hasMeaningfulResponsesOutputItem(item));
|
||||
}
|
||||
|
||||
function shouldFailEmptyResponsesCompletion(input: {
|
||||
@@ -116,59 +87,6 @@ function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function preserveMeaningfulTerminalResponsesPayload(
|
||||
lines: string[],
|
||||
eventType: 'response.completed' | 'response.incomplete',
|
||||
payload: Record<string, unknown>,
|
||||
): string[] {
|
||||
const responsePayload = isRecord(payload.response) ? payload.response : null;
|
||||
if (!responsePayload || !hasMeaningfulResponsesPayloadOutput(responsePayload)) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const parsed = openAiResponsesStream.pullSseEvents(lines.join(''));
|
||||
let replaced = false;
|
||||
const nextLines: string[] = [];
|
||||
|
||||
for (const event of parsed.events) {
|
||||
if (event.data === '[DONE]') {
|
||||
nextLines.push('data: [DONE]\n\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsedPayload: unknown = null;
|
||||
try {
|
||||
parsedPayload = JSON.parse(event.data);
|
||||
} catch {
|
||||
nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isRecord(parsedPayload)
|
||||
&& asTrimmedString(parsedPayload.type) === eventType
|
||||
&& isRecord(parsedPayload.response)
|
||||
&& !hasMeaningfulResponsesPayloadOutput(parsedPayload.response)
|
||||
) {
|
||||
replaced = true;
|
||||
nextLines.push(
|
||||
`event: ${event.event || eventType}\ndata: ${JSON.stringify({
|
||||
...parsedPayload,
|
||||
response: {
|
||||
...parsedPayload.response,
|
||||
...responsePayload,
|
||||
},
|
||||
})}\n\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`);
|
||||
}
|
||||
|
||||
return replaced ? nextLines : lines;
|
||||
}
|
||||
|
||||
export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) {
|
||||
const streamContext = openAiResponsesStream.createContext(input.modelName);
|
||||
const responsesState = createOpenAiResponsesAggregateState(input.modelName);
|
||||
@@ -268,9 +186,9 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
usage: input.getUsage(),
|
||||
});
|
||||
if (isIncompleteEvent) {
|
||||
convertedLines = preserveMeaningfulTerminalResponsesPayload(convertedLines, 'response.incomplete', parsedPayload);
|
||||
convertedLines = preserveMeaningfulResponsesTerminalPayload(convertedLines, 'response.incomplete', parsedPayload);
|
||||
} else if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') {
|
||||
convertedLines = preserveMeaningfulTerminalResponsesPayload(convertedLines, 'response.completed', parsedPayload);
|
||||
convertedLines = preserveMeaningfulResponsesTerminalPayload(convertedLines, 'response.completed', parsedPayload);
|
||||
}
|
||||
if (
|
||||
(eventBlock.event === 'response.completed' || payloadType === 'response.completed')
|
||||
@@ -314,27 +232,22 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
const payloadType = (isRecord(payload) && typeof payload.type === 'string')
|
||||
? payload.type
|
||||
: '';
|
||||
const payloadStatus = isRecord(payload) && typeof payload.status === 'string'
|
||||
? payload.status
|
||||
: '';
|
||||
if (payloadType === 'error' || payloadType === 'response.failed') {
|
||||
fail(payload);
|
||||
response?.end();
|
||||
return terminalResult;
|
||||
}
|
||||
|
||||
const normalizedFinal = openAiResponsesOutbound.normalizeFinal(payload, input.modelName, fallbackText);
|
||||
const serializedFinal = serializeResponsesUpstreamFinalAsStream({
|
||||
payload,
|
||||
modelName: input.modelName,
|
||||
fallbackText,
|
||||
usage: input.getUsage(),
|
||||
});
|
||||
const { normalizedFinal, streamPayload, isIncompletePayload, lines } = serializedFinal;
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
|
||||
const streamPayload = openAiResponsesOutbound.serializeFinal({
|
||||
upstreamPayload: payload,
|
||||
normalized: normalizedFinal,
|
||||
usage: input.getUsage(),
|
||||
serializationMode: 'response',
|
||||
});
|
||||
const isIncompletePayload = payloadType === 'response.incomplete' || payloadStatus === 'incomplete';
|
||||
if (!isIncompletePayload && shouldFailEmptyResponsesCompletion({
|
||||
payload: { type: 'response.completed', response: streamPayload },
|
||||
state: responsesState,
|
||||
@@ -349,36 +262,13 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
response?.end();
|
||||
return terminalResult;
|
||||
}
|
||||
const createdPayload = {
|
||||
...streamPayload,
|
||||
status: 'in_progress',
|
||||
output: [],
|
||||
output_text: '',
|
||||
};
|
||||
|
||||
if (isIncompletePayload) {
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
input.writeLines([
|
||||
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||
`event: response.incomplete\ndata: ${JSON.stringify({ type: 'response.incomplete', response: streamPayload })}\n\n`,
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
} else {
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
input.writeLines([
|
||||
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`,
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
}
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
input.writeLines(lines);
|
||||
response?.end();
|
||||
return terminalResult;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCanonicalRequestToOpenAiResponsesBody,
|
||||
parseOpenAiResponsesRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
describe('openai responses request bridge', () => {
|
||||
it('parses OpenAI Responses bodies into canonical envelopes', () => {
|
||||
const result = parseOpenAiResponsesRequestToCanonical({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
requestedModel: 'gpt-5',
|
||||
continuation: {
|
||||
previousResponseId: 'resp_prev_1',
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds OpenAI Responses bodies from canonical envelopes', () => {
|
||||
const body = buildCanonicalRequestToOpenAiResponsesBody({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
previousResponseId: 'resp_prev_1',
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
store: false,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Responses tool-turn history from canonical assistant reasoning and tool results', () => {
|
||||
const body = buildCanonicalRequestToOpenAiResponsesBody({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'plan quietly',
|
||||
thought: true,
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_1',
|
||||
name: 'Glob',
|
||||
argumentsJson: '{"pattern":"README*"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: 'call_1',
|
||||
resultText: 'README.md',
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
store: false,
|
||||
include: ['reasoning.encrypted_content'],
|
||||
input: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
summary: [{
|
||||
type: 'summary_text',
|
||||
text: 'plan quietly',
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_1',
|
||||
name: 'Glob',
|
||||
arguments: '{"pattern":"README*"}',
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: 'README.md',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/openAiRequestBridge.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolParseContext } from '../../contracts.js';
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
} from './conversion.js';
|
||||
import { openAiResponsesInbound } from './inbound.js';
|
||||
|
||||
function normalizeIncludeList(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
export function parseOpenAiResponsesRequestToCanonical(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = openAiResponsesInbound.parse(body, {
|
||||
defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude,
|
||||
});
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid responses request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const responsesBody = parsed.value.parsed.normalizedBody;
|
||||
const openAiBody = convertResponsesBodyToOpenAiBody(
|
||||
responsesBody,
|
||||
typeof responsesBody.model === 'string' ? responsesBody.model : parsed.value.model,
|
||||
responsesBody.stream === true,
|
||||
{ defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude },
|
||||
);
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'openai-responses',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCanonicalRequestToOpenAiResponsesBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
if (request.reasoning) {
|
||||
openAiBody.reasoning = {
|
||||
...(request.reasoning.effort ? { effort: request.reasoning.effort } : {}),
|
||||
...(request.reasoning.budgetTokens !== undefined ? { budget_tokens: request.reasoning.budgetTokens } : {}),
|
||||
...(request.reasoning.summary ? { summary: request.reasoning.summary } : {}),
|
||||
};
|
||||
}
|
||||
const body = convertOpenAiBodyToResponsesBody(openAiBody, request.requestedModel, request.stream);
|
||||
const mergedInclude = Array.from(new Set([
|
||||
'reasoning.encrypted_content',
|
||||
...normalizeIncludeList(body.include),
|
||||
]));
|
||||
return {
|
||||
...body,
|
||||
include: mergedInclude,
|
||||
store: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildNormalizedFinalToOpenAiResponsesPayload,
|
||||
normalizeOpenAiResponsesFinalToNormalized,
|
||||
openAiResponsesResponseBridge,
|
||||
} from './responseBridge.js';
|
||||
import { openAiResponsesOutbound } from './outbound.js';
|
||||
|
||||
describe('openai responses response bridge', () => {
|
||||
it('normalizes upstream responses finals into normalized final responses', () => {
|
||||
const normalized = normalizeOpenAiResponsesFinalToNormalized({
|
||||
id: 'resp_native_1',
|
||||
object: 'response',
|
||||
created_at: 1700000000,
|
||||
model: 'gpt-5',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'hello',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}, 'gpt-5');
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
id: 'resp_native_1',
|
||||
model: 'gpt-5',
|
||||
content: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds responses payloads from normalized final responses', () => {
|
||||
const payload = buildNormalizedFinalToOpenAiResponsesPayload({
|
||||
upstreamPayload: {
|
||||
id: 'opaque_1',
|
||||
model: 'gpt-5',
|
||||
},
|
||||
normalized: {
|
||||
id: 'opaque_1',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: 'hello',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
id: 'resp_opaque_1',
|
||||
object: 'response',
|
||||
created_at: 1700000000,
|
||||
status: 'completed',
|
||||
model: 'gpt-5',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_opaque_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'hello',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
output_text: 'hello',
|
||||
usage: {
|
||||
input_tokens: 11,
|
||||
output_tokens: 7,
|
||||
total_tokens: 18,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves function-call chains from response-like upstream payloads instead of flattening them into text', () => {
|
||||
const payload = buildNormalizedFinalToOpenAiResponsesPayload({
|
||||
upstreamPayload: {
|
||||
id: 'opaque_tool_chain_1',
|
||||
model: 'gpt-5',
|
||||
output: [
|
||||
{
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'plan first',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fc_1',
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{
|
||||
id: 'fco_1',
|
||||
type: 'function_call_output',
|
||||
status: 'completed',
|
||||
call_id: 'call_1',
|
||||
output: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: '22C',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
normalized: {
|
||||
id: 'opaque_tool_chain_1',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: 'done',
|
||||
reasoningContent: 'plan first',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [{
|
||||
id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
}],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
id: 'resp_opaque_tool_chain_1',
|
||||
object: 'response',
|
||||
output_text: 'done',
|
||||
output: [
|
||||
{
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'plan first',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fc_1',
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{
|
||||
id: 'fco_1',
|
||||
type: 'function_call_output',
|
||||
status: 'completed',
|
||||
call_id: 'call_1',
|
||||
output: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: '22C',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the outbound facade pointed at the response bridge object', () => {
|
||||
expect(openAiResponsesOutbound).toBe(openAiResponsesResponseBridge);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import {
|
||||
normalizeUpstreamFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js';
|
||||
import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJson(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJson(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ensureResponseId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureMessageId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `msg_${Date.now()}`;
|
||||
return trimmed.startsWith('msg_') ? trimmed : `msg_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureFunctionCallId(rawId: string): string {
|
||||
const trimmed = rawId.trim();
|
||||
if (!trimmed) return `call_${Date.now()}`;
|
||||
return trimmed.startsWith('call_') ? trimmed : `call_${trimmed}`;
|
||||
}
|
||||
|
||||
function stringifyToolInput(input: unknown): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
try {
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export type ResponsesUsageSummary = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
inputTokensDetails?: Record<string, unknown>;
|
||||
outputTokensDetails?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ResponsesToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
export type ResponsesFinalSerializationMode = 'response' | 'compact';
|
||||
|
||||
type ResponsesOutputItem = Record<string, unknown>;
|
||||
|
||||
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
if (Array.isArray(payload.choices)) {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord((choice as any)?.message) ? (choice as any).message : {};
|
||||
const toolCalls = Array.isArray((message as any).tool_calls) ? (message as any).tool_calls : [];
|
||||
return toolCalls
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const fn = isRecord(item.function) ? item.function : {};
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof fn.name === 'string' ? fn.name : '';
|
||||
const args = typeof fn.arguments === 'string' ? fn.arguments : '';
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
if (payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
return payload.content
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item) || item.type !== 'tool_use') return null;
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const args = stringifyToolInput(item.input);
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractToolCallsFromNormalized(normalized: NormalizedFinalResponse): ResponsesToolCall[] {
|
||||
return Array.isArray(normalized.toolCalls)
|
||||
? normalized.toolCalls
|
||||
.map((item) => {
|
||||
const id = ensureFunctionCallId(asTrimmedString(item.id));
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
arguments: typeof item.arguments === 'string' ? item.arguments : '',
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item)
|
||||
: [];
|
||||
}
|
||||
|
||||
function extractAnnotationsFromUpstream(payload: unknown): unknown[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
if (Array.isArray(payload.choices)) {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord((choice as any)?.message) ? (choice as any).message : {};
|
||||
const annotations = Array.isArray((message as any).annotations) ? (message as any).annotations : [];
|
||||
return annotations.map((item) => cloneJson(item));
|
||||
}
|
||||
|
||||
if (payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
const annotations = payload.content
|
||||
.filter((item): item is Record<string, unknown> => isRecord(item))
|
||||
.flatMap((item) => (Array.isArray(item.annotations) ? item.annotations : []));
|
||||
return annotations.map((item) => cloneJson(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOutputItem[] {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.output)) return [];
|
||||
|
||||
return payload.output
|
||||
.map((rawItem): ResponsesOutputItem | null => {
|
||||
if (!isRecord(rawItem)) return null;
|
||||
const itemType = asTrimmedString(rawItem.type).toLowerCase();
|
||||
if (!itemType) return null;
|
||||
|
||||
if (itemType === 'message') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'message',
|
||||
role: asTrimmedString(rawItem.role) || 'assistant',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
content: Array.isArray(rawItem.content) ? cloneJson(rawItem.content) : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'reasoning') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'reasoning',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
encrypted_content: rawItem.encrypted_content,
|
||||
summary: Array.isArray(rawItem.summary) ? cloneJson(rawItem.summary) : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'function_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''),
|
||||
type: 'function_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)),
|
||||
name: asTrimmedString(rawItem.name),
|
||||
arguments: typeof rawItem.arguments === 'string' ? rawItem.arguments : '',
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'custom_tool_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''),
|
||||
type: 'custom_tool_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)),
|
||||
name: asTrimmedString(rawItem.name),
|
||||
input: typeof rawItem.input === 'string' ? rawItem.input : '',
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: itemType,
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id),
|
||||
output: cloneJson(rawItem.output),
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'image_generation_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'image_generation_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
result: cloneJson(rawItem.result),
|
||||
partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [],
|
||||
...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}),
|
||||
...(rawItem.mime_type !== undefined ? { mime_type: cloneJson(rawItem.mime_type) } : {}),
|
||||
...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}),
|
||||
...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}),
|
||||
...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}),
|
||||
...(rawItem.revised_prompt !== undefined ? { revised_prompt: cloneJson(rawItem.revised_prompt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType.startsWith('mcp_')) {
|
||||
return cloneJson(rawItem);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is ResponsesOutputItem => item !== null);
|
||||
}
|
||||
|
||||
function collectOutputTextFromItems(items: ResponsesOutputItem[]): string {
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) continue;
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
if ((partType === 'output_text' || partType === 'text') && typeof part.text === 'string') {
|
||||
textParts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('');
|
||||
}
|
||||
|
||||
function extractSyntheticUsageDetails(upstreamPayload: unknown): {
|
||||
inputTokensDetails?: Record<string, unknown>;
|
||||
outputTokensDetails?: Record<string, unknown>;
|
||||
} {
|
||||
if (!isRecord(upstreamPayload) || !isRecord(upstreamPayload.usage)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const usage = upstreamPayload.usage;
|
||||
const promptDetails = (
|
||||
(isRecord(usage.input_tokens_details) ? usage.input_tokens_details : null)
|
||||
?? (isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : null)
|
||||
);
|
||||
const completionDetails = (
|
||||
(isRecord(usage.output_tokens_details) ? usage.output_tokens_details : null)
|
||||
?? (isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : null)
|
||||
);
|
||||
|
||||
return {
|
||||
...(promptDetails ? { inputTokensDetails: cloneJson(promptDetails) } : {}),
|
||||
...(completionDetails ? { outputTokensDetails: cloneJson(completionDetails) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOpenAiResponsesFinalToNormalized(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText = '',
|
||||
): NormalizedFinalResponse {
|
||||
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
|
||||
}
|
||||
|
||||
function buildResponsesUsagePayload(
|
||||
usage: ResponsesUsageSummary,
|
||||
syntheticUsageDetails: {
|
||||
inputTokensDetails?: Record<string, unknown>;
|
||||
outputTokensDetails?: Record<string, unknown>;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails
|
||||
? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) }
|
||||
: {}),
|
||||
...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails
|
||||
? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNormalizedFinalToOpenAiResponsesPayload(input: {
|
||||
upstreamPayload: unknown;
|
||||
normalized: NormalizedFinalResponse;
|
||||
usage: ResponsesUsageSummary;
|
||||
serializationMode?: ResponsesFinalSerializationMode;
|
||||
}): Record<string, unknown> {
|
||||
const {
|
||||
upstreamPayload,
|
||||
normalized,
|
||||
usage,
|
||||
serializationMode = 'response',
|
||||
} = input;
|
||||
if (isRecord(upstreamPayload)) {
|
||||
if (upstreamPayload.object === 'response.compaction') {
|
||||
return upstreamPayload;
|
||||
}
|
||||
if (serializationMode === 'response' && upstreamPayload.object === 'response') {
|
||||
return upstreamPayload;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedId = typeof normalized.id === 'string' && normalized.id.trim()
|
||||
? normalized.id.trim()
|
||||
: `resp_${Date.now()}`;
|
||||
const responseId = ensureResponseId(normalizedId);
|
||||
const messageId = ensureMessageId(normalizedId);
|
||||
const syntheticOutput = extractSyntheticOutputItemsFromUpstream(upstreamPayload);
|
||||
const syntheticUsageDetails = extractSyntheticUsageDetails(upstreamPayload);
|
||||
const toolCalls = (() => {
|
||||
const extracted = extractToolCallsFromUpstream(upstreamPayload);
|
||||
if (extracted.length > 0) return extracted;
|
||||
return extractToolCallsFromNormalized(normalized);
|
||||
})();
|
||||
const rawReasoningSignature = typeof normalized.reasoningSignature === 'string'
|
||||
? normalized.reasoningSignature.trim()
|
||||
: '';
|
||||
const encryptedReasoning = decodeOpenAiEncryptedReasoning(rawReasoningSignature)
|
||||
?? (rawReasoningSignature && !rawReasoningSignature.startsWith('metapi:') ? rawReasoningSignature : null);
|
||||
const annotations = extractAnnotationsFromUpstream(upstreamPayload);
|
||||
|
||||
const output: Array<Record<string, unknown>> = syntheticOutput.map((item) => cloneJson(item));
|
||||
const hasReasoningItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'reasoning');
|
||||
const hasMessageItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'message');
|
||||
const hasToolLikeItem = output.some((item) => {
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
return (
|
||||
itemType === 'function_call'
|
||||
|| itemType === 'custom_tool_call'
|
||||
|| itemType === 'function_call_output'
|
||||
|| itemType === 'custom_tool_call_output'
|
||||
|| itemType === 'image_generation_call'
|
||||
);
|
||||
});
|
||||
|
||||
if ((normalized.reasoningContent || encryptedReasoning) && !hasReasoningItem) {
|
||||
const reasoningItem: Record<string, unknown> = {
|
||||
id: ensureMessageId(`${normalizedId}_reasoning`),
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: normalized.reasoningContent
|
||||
? [{
|
||||
type: 'summary_text',
|
||||
text: normalized.reasoningContent,
|
||||
}]
|
||||
: [],
|
||||
};
|
||||
if (encryptedReasoning) {
|
||||
reasoningItem.encrypted_content = encryptedReasoning;
|
||||
}
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
|
||||
if ((normalized.content || (!hasToolLikeItem && output.length === 0 && toolCalls.length === 0)) && !hasMessageItem) {
|
||||
const textPart: Record<string, unknown> = {
|
||||
type: 'output_text',
|
||||
text: normalized.content || '',
|
||||
};
|
||||
if (annotations.length > 0) {
|
||||
textPart.annotations = annotations;
|
||||
}
|
||||
output.push({
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [textPart],
|
||||
});
|
||||
}
|
||||
|
||||
if (toolCalls.length > 0 && !hasToolLikeItem) {
|
||||
for (const toolCall of toolCalls) {
|
||||
const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments);
|
||||
if (mcpItem) {
|
||||
output.push(mcpItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
id: toolCall.id,
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const usagePayload = buildResponsesUsagePayload(usage, syntheticUsageDetails);
|
||||
if (serializationMode === 'compact') {
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'response.compaction',
|
||||
created_at: normalized.created,
|
||||
output,
|
||||
usage: usagePayload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created_at: normalized.created,
|
||||
status: 'completed',
|
||||
model: normalized.model,
|
||||
output,
|
||||
output_text: normalized.content || collectOutputTextFromItems(output),
|
||||
usage: usagePayload,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
normalizeOpenAiResponsesFinalToNormalized as normalizeResponsesFinalPayload,
|
||||
buildNormalizedFinalToOpenAiResponsesPayload as serializeResponsesFinalPayload,
|
||||
buildNormalizedFinalToOpenAiResponsesPayload as toResponsesPayload,
|
||||
};
|
||||
|
||||
export const openAiResponsesResponseBridge = {
|
||||
normalizeFinal: normalizeOpenAiResponsesFinalToNormalized,
|
||||
serializeFinal: buildNormalizedFinalToOpenAiResponsesPayload,
|
||||
};
|
||||
|
||||
export const openAiResponsesOutbound = openAiResponsesResponseBridge;
|
||||
@@ -38,6 +38,7 @@ type UpstreamResponse = Exclude<EndpointRecoverResult, null>['upstream'];
|
||||
type CreateResponsesEndpointStrategyInput = {
|
||||
isStream: boolean;
|
||||
requiresNativeResponsesFileUrl: boolean;
|
||||
sitePlatform?: string;
|
||||
dispatchRequest: (
|
||||
request: CompatibilityRequest,
|
||||
targetUrl?: string,
|
||||
@@ -51,12 +52,47 @@ export function createResponsesEndpointStrategy(input: CreateResponsesEndpointSt
|
||||
endpoint: ctx.request.endpoint,
|
||||
status: ctx.response.status,
|
||||
rawErrText: ctx.rawErrText,
|
||||
body: ctx.request.body,
|
||||
})) {
|
||||
const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body);
|
||||
const normalizedSitePlatform = String(input.sitePlatform || '').trim().toLowerCase();
|
||||
const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body, {
|
||||
sitePlatform: input.sitePlatform,
|
||||
});
|
||||
const compatibilityHeaders = buildResponsesCompatibilityHeaderCandidates(
|
||||
ctx.request.headers,
|
||||
input.isStream,
|
||||
{
|
||||
sitePlatform: input.sitePlatform,
|
||||
},
|
||||
);
|
||||
const alternateCompatibilityHeaders = compatibilityHeaders.slice(1);
|
||||
|
||||
if (
|
||||
normalizedSitePlatform === 'sub2api'
|
||||
&& compatibilityBodies.length === 0
|
||||
&& alternateCompatibilityHeaders.length > 0
|
||||
) {
|
||||
for (const compatibilityHeadersCandidate of alternateCompatibilityHeaders) {
|
||||
const compatibilityRequest = {
|
||||
...ctx.request,
|
||||
headers: compatibilityHeadersCandidate,
|
||||
};
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
for (const compatibilityHeadersCandidate of compatibilityHeaders) {
|
||||
for (const compatibilityBody of compatibilityBodies) {
|
||||
|
||||
@@ -1,81 +1,11 @@
|
||||
import {
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
type NormalizedStreamEvent,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
completeResponsesStream,
|
||||
createOpenAiResponsesAggregateState,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
type OpenAiResponsesAggregateState,
|
||||
} from './aggregator.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
export type OpenAiResponsesStreamEvent = NormalizedStreamEvent & {
|
||||
responsesEventType?: string;
|
||||
responsesPayload?: Record<string, unknown>;
|
||||
usagePayload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const openAiResponsesStream = {
|
||||
eventNames: [
|
||||
'response.created',
|
||||
'response.in_progress',
|
||||
'response.output_item.added',
|
||||
'response.output_item.done',
|
||||
'response.content_part.added',
|
||||
'response.content_part.done',
|
||||
'response.output_text.delta',
|
||||
'response.output_text.done',
|
||||
'response.function_call_arguments.delta',
|
||||
'response.function_call_arguments.done',
|
||||
'response.custom_tool_call_input.delta',
|
||||
'response.custom_tool_call_input.done',
|
||||
'response.reasoning_summary_part.added',
|
||||
'response.reasoning_summary_part.done',
|
||||
'response.reasoning_summary_text.delta',
|
||||
'response.reasoning_summary_text.done',
|
||||
'response.image_generation_call.generating',
|
||||
'response.image_generation_call.in_progress',
|
||||
'response.image_generation_call.partial_image',
|
||||
'response.image_generation_call.completed',
|
||||
'response.completed',
|
||||
'response.incomplete',
|
||||
'response.failed',
|
||||
] as const,
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiResponsesStreamEvent {
|
||||
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName) as OpenAiResponsesStreamEvent;
|
||||
if (isRecord(payload) && typeof payload.type === 'string' && payload.type.startsWith('response.')) {
|
||||
normalized.responsesEventType = payload.type;
|
||||
normalized.responsesPayload = payload;
|
||||
}
|
||||
if (isRecord(payload) && isRecord(payload.usage)) {
|
||||
normalized.usagePayload = payload.usage;
|
||||
}
|
||||
return normalized;
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return pullSseEventsWithDone(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export type ResponsesStreamState = OpenAiResponsesAggregateState;
|
||||
|
||||
export function createResponsesStreamState(modelName: string): ResponsesStreamState {
|
||||
return createOpenAiResponsesAggregateState(modelName);
|
||||
}
|
||||
|
||||
export type {
|
||||
OpenAiResponsesStreamEvent,
|
||||
ResponsesStreamState,
|
||||
} from './streamBridge.js';
|
||||
export {
|
||||
completeResponsesStream,
|
||||
createResponsesStreamState,
|
||||
failResponsesStream,
|
||||
openAiResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
};
|
||||
} from './streamBridge.js';
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
openAiResponsesStream,
|
||||
preserveMeaningfulResponsesTerminalPayload,
|
||||
serializeResponsesUpstreamFinalAsStream,
|
||||
} from './streamBridge.js';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventLine ? eventLine.slice('event: '.length) : null,
|
||||
payload: JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>,
|
||||
};
|
||||
})
|
||||
.filter((item): item is { event: string | null; payload: Record<string, unknown> | '[DONE]' } => !!item);
|
||||
}
|
||||
|
||||
describe('openai responses stream bridge', () => {
|
||||
it('normalizes response stream payload metadata onto normalized events', () => {
|
||||
const context = openAiResponsesStream.createContext('gpt-5');
|
||||
const payload = {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_1',
|
||||
status: 'completed',
|
||||
},
|
||||
usage: {
|
||||
input_tokens: 11,
|
||||
output_tokens: 7,
|
||||
total_tokens: 18,
|
||||
},
|
||||
};
|
||||
|
||||
const normalized = openAiResponsesStream.normalizeEvent(payload, context, 'gpt-5');
|
||||
|
||||
expect(normalized.responsesEventType).toBe('response.completed');
|
||||
expect(normalized.responsesPayload).toEqual(payload);
|
||||
expect(normalized.usagePayload).toEqual(payload.usage);
|
||||
});
|
||||
|
||||
it('pulls response SSE events and preserves the DONE terminator', () => {
|
||||
const parsed = openAiResponsesStream.pullSseEvents([
|
||||
'event: response.completed',
|
||||
'data: {"type":"response.completed"}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
expect(parsed.events).toEqual([
|
||||
{
|
||||
event: 'response.completed',
|
||||
data: '{"type":"response.completed"}',
|
||||
},
|
||||
{
|
||||
event: '',
|
||||
data: '[DONE]',
|
||||
},
|
||||
]);
|
||||
expect(parsed.rest).toBe('');
|
||||
});
|
||||
|
||||
it('serializes upstream final payloads into response.created and response.completed SSE lines', () => {
|
||||
const serialized = serializeResponsesUpstreamFinalAsStream({
|
||||
payload: {
|
||||
id: 'resp_fallback_1',
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
model: 'gpt-5',
|
||||
output_text: 'hello from fallback',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_fallback_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'hello from fallback' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
modelName: 'gpt-5',
|
||||
fallbackText: 'hello from fallback',
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
});
|
||||
|
||||
expect(serialized.isIncompletePayload).toBe(false);
|
||||
expect(serialized.lines.join('')).toContain('event: response.created');
|
||||
expect(serialized.lines.join('')).toContain('event: response.completed');
|
||||
expect(serialized.lines.join('')).toContain('"output_text":"hello from fallback"');
|
||||
expect(serialized.lines.join('')).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('serializes fallback final tool and reasoning output items as canonical Responses SSE events before completion', () => {
|
||||
const serialized = serializeResponsesUpstreamFinalAsStream({
|
||||
payload: {
|
||||
id: 'resp_semantic_fallback_1',
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
model: 'gpt-5',
|
||||
output_text: 'final answer',
|
||||
output: [
|
||||
{
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [{ type: 'summary_text', text: 'plan quietly' }],
|
||||
},
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'final answer' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
modelName: 'gpt-5',
|
||||
fallbackText: 'final answer',
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const events = parseSseEvents(serialized.lines);
|
||||
|
||||
expect(events.map((entry) => entry.event)).toEqual([
|
||||
'response.created',
|
||||
'response.reasoning_summary_text.done',
|
||||
'response.reasoning_summary_part.done',
|
||||
'response.output_item.done',
|
||||
'response.function_call_arguments.done',
|
||||
'response.output_item.done',
|
||||
'response.output_text.done',
|
||||
'response.content_part.done',
|
||||
'response.output_item.done',
|
||||
'response.completed',
|
||||
null,
|
||||
]);
|
||||
expect(events[4]?.payload).toMatchObject({
|
||||
type: 'response.function_call_arguments.done',
|
||||
call_id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
});
|
||||
expect(events[9]?.payload).toMatchObject({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_semantic_fallback_1',
|
||||
output_text: 'final answer',
|
||||
output: [
|
||||
{
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
},
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function_call',
|
||||
call_id: 'call_1',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(events[10]?.payload).toBe('[DONE]');
|
||||
});
|
||||
|
||||
it('preserves meaningful terminal response payload output when the synthesized terminal event is empty', () => {
|
||||
const preserved = preserveMeaningfulResponsesTerminalPayload(
|
||||
[
|
||||
'event: response.completed\n',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_empty',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
output_text: '',
|
||||
},
|
||||
})}\n\n`,
|
||||
'data: [DONE]\n\n',
|
||||
],
|
||||
'response.completed',
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_full',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_full',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'restored text' }],
|
||||
},
|
||||
],
|
||||
output_text: 'restored text',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(preserved.join('')).toContain('"output_text":"restored text"');
|
||||
expect(preserved.join('')).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('keeps response tool-call chains intact in completed SSE fallback payloads', () => {
|
||||
const serialized = serializeResponsesUpstreamFinalAsStream({
|
||||
payload: {
|
||||
id: 'resp_tool_stream_1',
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
model: 'gpt-5',
|
||||
output_text: 'done',
|
||||
output: [
|
||||
{
|
||||
id: 'fc_1',
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{
|
||||
id: 'fco_1',
|
||||
type: 'function_call_output',
|
||||
status: 'completed',
|
||||
call_id: 'call_1',
|
||||
output: '22C',
|
||||
},
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'done' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
modelName: 'gpt-5',
|
||||
fallbackText: 'done',
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
});
|
||||
|
||||
expect(serialized.lines.join('')).toContain('event: response.completed');
|
||||
expect(serialized.lines.join('')).toContain('"type":"function_call"');
|
||||
expect(serialized.lines.join('')).toContain('"type":"function_call_output"');
|
||||
expect(serialized.lines.join('')).toContain('"call_id":"call_1"');
|
||||
expect(serialized.lines.join('')).toContain('"output_text":"done"');
|
||||
});
|
||||
|
||||
it('keeps incomplete fallback item statuses aligned with the terminal response status when item statuses are omitted', () => {
|
||||
const serialized = serializeResponsesUpstreamFinalAsStream({
|
||||
payload: {
|
||||
id: 'resp_incomplete_1',
|
||||
object: 'response',
|
||||
status: 'incomplete',
|
||||
model: 'gpt-5',
|
||||
output_text: 'partial',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_partial_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'partial' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
modelName: 'gpt-5',
|
||||
fallbackText: 'partial',
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const events = parseSseEvents(serialized.lines);
|
||||
|
||||
expect(events.map((entry) => entry.event)).toEqual([
|
||||
'response.created',
|
||||
'response.output_text.done',
|
||||
'response.content_part.done',
|
||||
'response.output_item.done',
|
||||
'response.incomplete',
|
||||
null,
|
||||
]);
|
||||
expect(events[3]?.payload).toMatchObject({
|
||||
type: 'response.output_item.done',
|
||||
item: {
|
||||
id: 'msg_partial_1',
|
||||
type: 'message',
|
||||
status: 'incomplete',
|
||||
},
|
||||
});
|
||||
expect(events[4]?.payload).toMatchObject({
|
||||
type: 'response.incomplete',
|
||||
response: {
|
||||
id: 'resp_incomplete_1',
|
||||
status: 'incomplete',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_partial_1',
|
||||
type: 'message',
|
||||
status: 'incomplete',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes failed fallback wrappers as response.failed and preserves the upstream error payload', () => {
|
||||
const serialized = serializeResponsesUpstreamFinalAsStream({
|
||||
payload: {
|
||||
type: 'response.failed',
|
||||
error: {
|
||||
message: 'upstream exploded',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
response: {
|
||||
id: 'resp_failed_1',
|
||||
status: 'failed',
|
||||
model: 'gpt-5',
|
||||
output_text: 'partial',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_failed_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'partial' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
modelName: 'gpt-5',
|
||||
fallbackText: 'partial',
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const events = parseSseEvents(serialized.lines);
|
||||
|
||||
expect(events.map((entry) => entry.event)).toEqual([
|
||||
'response.created',
|
||||
'response.output_text.done',
|
||||
'response.content_part.done',
|
||||
'response.output_item.done',
|
||||
'response.failed',
|
||||
null,
|
||||
]);
|
||||
expect(events[3]?.payload).toMatchObject({
|
||||
type: 'response.output_item.done',
|
||||
item: {
|
||||
id: 'msg_failed_1',
|
||||
type: 'message',
|
||||
status: 'failed',
|
||||
},
|
||||
});
|
||||
expect(events[4]?.payload).toMatchObject({
|
||||
type: 'response.failed',
|
||||
error: {
|
||||
message: 'upstream exploded',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
response: {
|
||||
id: 'resp_failed_1',
|
||||
status: 'failed',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_failed_1',
|
||||
type: 'message',
|
||||
status: 'failed',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
type NormalizedStreamEvent,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
completeResponsesStream,
|
||||
createOpenAiResponsesAggregateState,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
type OpenAiResponsesAggregateState,
|
||||
} from './aggregator.js';
|
||||
import {
|
||||
buildNormalizedFinalToOpenAiResponsesPayload,
|
||||
normalizeOpenAiResponsesFinalToNormalized,
|
||||
type ResponsesUsageSummary,
|
||||
} from './responseBridge.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJson(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJson(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasMeaningfulContentPart(part: unknown): boolean {
|
||||
if (!isRecord(part)) return false;
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
if (partType === 'output_text' || partType === 'text') {
|
||||
return hasNonEmptyString(part.text);
|
||||
}
|
||||
return partType.length > 0;
|
||||
}
|
||||
|
||||
export function hasMeaningfulResponsesOutputItem(item: unknown): boolean {
|
||||
if (!isRecord(item)) return false;
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (itemType === 'message') {
|
||||
return Array.isArray(item.content) && item.content.some((part) => hasMeaningfulContentPart(part));
|
||||
}
|
||||
if (itemType === 'reasoning') {
|
||||
return (
|
||||
(Array.isArray(item.summary) && item.summary.some((part) => hasMeaningfulContentPart(part)))
|
||||
|| hasNonEmptyString(item.encrypted_content)
|
||||
);
|
||||
}
|
||||
return itemType.length > 0;
|
||||
}
|
||||
|
||||
export function hasMeaningfulResponsesPayloadOutput(payload: unknown): boolean {
|
||||
if (!isRecord(payload)) return false;
|
||||
if (hasNonEmptyString(payload.output_text)) return true;
|
||||
return Array.isArray(payload.output) && payload.output.some((item) => hasMeaningfulResponsesOutputItem(item));
|
||||
}
|
||||
|
||||
export function preserveMeaningfulResponsesTerminalPayload(
|
||||
lines: string[],
|
||||
eventType: 'response.completed' | 'response.incomplete',
|
||||
payload: Record<string, unknown>,
|
||||
): string[] {
|
||||
const responsePayload = isRecord(payload.response) ? payload.response : null;
|
||||
if (!responsePayload || !hasMeaningfulResponsesPayloadOutput(responsePayload)) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const parsed = openAiResponsesStream.pullSseEvents(lines.join(''));
|
||||
let replaced = false;
|
||||
const nextLines: string[] = [];
|
||||
|
||||
for (const event of parsed.events) {
|
||||
if (event.data === '[DONE]') {
|
||||
nextLines.push('data: [DONE]\n\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsedPayload: unknown = null;
|
||||
try {
|
||||
parsedPayload = JSON.parse(event.data);
|
||||
} catch {
|
||||
nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isRecord(parsedPayload)
|
||||
&& asTrimmedString(parsedPayload.type) === eventType
|
||||
&& isRecord(parsedPayload.response)
|
||||
&& !hasMeaningfulResponsesPayloadOutput(parsedPayload.response)
|
||||
) {
|
||||
replaced = true;
|
||||
nextLines.push(
|
||||
`event: ${event.event || eventType}\ndata: ${JSON.stringify({
|
||||
...parsedPayload,
|
||||
response: {
|
||||
...parsedPayload.response,
|
||||
...responsePayload,
|
||||
},
|
||||
})}\n\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`);
|
||||
}
|
||||
|
||||
return replaced ? nextLines : lines;
|
||||
}
|
||||
|
||||
export function serializeResponsesUpstreamFinalAsStream(input: {
|
||||
payload: unknown;
|
||||
modelName: string;
|
||||
fallbackText: string;
|
||||
usage: ResponsesUsageSummary;
|
||||
}): {
|
||||
normalizedFinal: ReturnType<typeof normalizeOpenAiResponsesFinalToNormalized>;
|
||||
streamPayload: Record<string, unknown>;
|
||||
createdPayload: Record<string, unknown>;
|
||||
isIncompletePayload: boolean;
|
||||
lines: string[];
|
||||
} {
|
||||
const { payload, modelName, fallbackText, usage } = input;
|
||||
const normalizedFinal = normalizeOpenAiResponsesFinalToNormalized(payload, modelName, fallbackText);
|
||||
const streamPayload = buildNormalizedFinalToOpenAiResponsesPayload({
|
||||
upstreamPayload: payload,
|
||||
normalized: normalizedFinal,
|
||||
usage,
|
||||
serializationMode: 'response',
|
||||
});
|
||||
const payloadType = (isRecord(payload) && typeof payload.type === 'string')
|
||||
? payload.type
|
||||
: '';
|
||||
const payloadStatus = isRecord(payload) && typeof payload.status === 'string'
|
||||
? payload.status
|
||||
: '';
|
||||
const isFailedPayload = payloadType === 'response.failed' || payloadStatus === 'failed';
|
||||
const isIncompletePayload = payloadType === 'response.incomplete' || payloadStatus === 'incomplete';
|
||||
const terminalStatus = isFailedPayload ? 'failed' : (isIncompletePayload ? 'incomplete' : 'completed');
|
||||
const upstreamTerminalResponse = isRecord(payload) && isRecord(payload.response)
|
||||
? cloneJson(payload.response)
|
||||
: null;
|
||||
const terminalResponsePayload = upstreamTerminalResponse
|
||||
? {
|
||||
...streamPayload,
|
||||
...upstreamTerminalResponse,
|
||||
...(Array.isArray(upstreamTerminalResponse.output) ? { output: cloneJson(upstreamTerminalResponse.output) } : {}),
|
||||
...(typeof upstreamTerminalResponse.output_text === 'string'
|
||||
? { output_text: upstreamTerminalResponse.output_text }
|
||||
: {}),
|
||||
status: terminalStatus,
|
||||
}
|
||||
: {
|
||||
...streamPayload,
|
||||
status: terminalStatus,
|
||||
};
|
||||
const createdPayload = {
|
||||
...terminalResponsePayload,
|
||||
status: 'in_progress',
|
||||
output: [],
|
||||
output_text: '',
|
||||
};
|
||||
|
||||
const terminalEventType = isFailedPayload
|
||||
? 'response.failed'
|
||||
: (isIncompletePayload ? 'response.incomplete' : 'response.completed');
|
||||
const fallbackState = createOpenAiResponsesAggregateState(normalizedFinal.model || modelName);
|
||||
const fallbackContext = createStreamTransformContext(normalizedFinal.model || modelName);
|
||||
fallbackContext.id = normalizedFinal.id;
|
||||
fallbackContext.model = normalizedFinal.model;
|
||||
fallbackContext.created = normalizedFinal.created;
|
||||
|
||||
const lines = [
|
||||
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||
];
|
||||
|
||||
const outputItems = Array.isArray(terminalResponsePayload.output) ? terminalResponsePayload.output : [];
|
||||
for (let outputIndex = 0; outputIndex < outputItems.length; outputIndex += 1) {
|
||||
const item = outputItems[outputIndex];
|
||||
if (!isRecord(item)) continue;
|
||||
const eventItem = cloneJson(item);
|
||||
const itemStatus = asTrimmedString(eventItem.status).toLowerCase();
|
||||
if ((!itemStatus || itemStatus === 'in_progress') && terminalStatus !== 'completed') {
|
||||
eventItem.status = terminalStatus;
|
||||
}
|
||||
lines.push(...serializeConvertedResponsesEvents({
|
||||
state: fallbackState,
|
||||
streamContext: fallbackContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.output_item.done',
|
||||
responsesPayload: {
|
||||
type: 'response.output_item.done',
|
||||
output_index: outputIndex,
|
||||
item: eventItem,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
lines.push(...serializeConvertedResponsesEvents({
|
||||
state: fallbackState,
|
||||
streamContext: fallbackContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: terminalEventType,
|
||||
responsesPayload: {
|
||||
type: terminalEventType,
|
||||
response: terminalResponsePayload,
|
||||
...(terminalEventType === 'response.failed' && isRecord(payload) && payload.error !== undefined
|
||||
? { error: cloneJson(payload.error) }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
lines.push('data: [DONE]\n\n');
|
||||
|
||||
return {
|
||||
normalizedFinal,
|
||||
streamPayload,
|
||||
createdPayload,
|
||||
isIncompletePayload,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenAiResponsesStreamEvent = NormalizedStreamEvent & {
|
||||
responsesEventType?: string;
|
||||
responsesPayload?: Record<string, unknown>;
|
||||
usagePayload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const openAiResponsesStream = {
|
||||
eventNames: [
|
||||
'response.created',
|
||||
'response.in_progress',
|
||||
'response.output_item.added',
|
||||
'response.output_item.done',
|
||||
'response.content_part.added',
|
||||
'response.content_part.done',
|
||||
'response.output_text.delta',
|
||||
'response.output_text.done',
|
||||
'response.function_call_arguments.delta',
|
||||
'response.function_call_arguments.done',
|
||||
'response.custom_tool_call_input.delta',
|
||||
'response.custom_tool_call_input.done',
|
||||
'response.reasoning_summary_part.added',
|
||||
'response.reasoning_summary_part.done',
|
||||
'response.reasoning_summary_text.delta',
|
||||
'response.reasoning_summary_text.done',
|
||||
'response.image_generation_call.generating',
|
||||
'response.image_generation_call.in_progress',
|
||||
'response.image_generation_call.partial_image',
|
||||
'response.image_generation_call.completed',
|
||||
'response.completed',
|
||||
'response.incomplete',
|
||||
'response.failed',
|
||||
] as const,
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiResponsesStreamEvent {
|
||||
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName) as OpenAiResponsesStreamEvent;
|
||||
if (isRecord(payload) && typeof payload.type === 'string' && payload.type.startsWith('response.')) {
|
||||
normalized.responsesEventType = payload.type;
|
||||
normalized.responsesPayload = payload;
|
||||
}
|
||||
if (isRecord(payload) && isRecord(payload.usage)) {
|
||||
normalized.usagePayload = payload.usage;
|
||||
}
|
||||
return normalized;
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return pullSseEventsWithDone(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export type ResponsesStreamState = OpenAiResponsesAggregateState;
|
||||
|
||||
export function createResponsesStreamState(modelName: string): ResponsesStreamState {
|
||||
return createOpenAiResponsesAggregateState(modelName);
|
||||
}
|
||||
|
||||
export {
|
||||
completeResponsesStream,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
convertClaudeRequestToOpenAiBody,
|
||||
createClaudeDownstreamContext,
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamFinalResponse,
|
||||
normalizeUpstreamStreamEvent,
|
||||
serializeNormalizedStreamEvent,
|
||||
} from './chatFormatsCore.js';
|
||||
@@ -338,6 +339,50 @@ describe('chatFormatsCore inline think parsing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps response.incomplete to stop unless the incomplete reason is max_output_tokens', () => {
|
||||
const context = createStreamTransformContext('gpt-test');
|
||||
|
||||
expect(normalizeUpstreamStreamEvent({
|
||||
type: 'response.incomplete',
|
||||
response: {
|
||||
id: 'resp_incomplete_stop',
|
||||
status: 'incomplete',
|
||||
},
|
||||
}, context, 'gpt-test')).toEqual({
|
||||
finishReason: 'stop',
|
||||
done: true,
|
||||
});
|
||||
|
||||
expect(normalizeUpstreamStreamEvent({
|
||||
type: 'response.incomplete',
|
||||
response: {
|
||||
id: 'resp_incomplete_length',
|
||||
status: 'incomplete',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
},
|
||||
}, context, 'gpt-test')).toEqual({
|
||||
finishReason: 'length',
|
||||
done: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps response.failed to a stop finish reason instead of inventing a chat error finish reason', () => {
|
||||
const context = createStreamTransformContext('gpt-test');
|
||||
|
||||
expect(normalizeUpstreamStreamEvent({
|
||||
type: 'response.failed',
|
||||
response: {
|
||||
id: 'resp_failed_stop',
|
||||
status: 'failed',
|
||||
},
|
||||
}, context, 'gpt-test')).toEqual({
|
||||
finishReason: 'stop',
|
||||
done: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not backfill historical tool identity into later arguments-only deltas', () => {
|
||||
const context = createStreamTransformContext('gpt-test');
|
||||
const claudeContext = createClaudeDownstreamContext();
|
||||
@@ -571,6 +616,38 @@ describe('chatFormatsCore inline think parsing', () => {
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps final Responses payload statuses to sub2api-like chat finish reasons', () => {
|
||||
expect(normalizeUpstreamFinalResponse({
|
||||
id: 'resp_final_stop',
|
||||
object: 'response',
|
||||
status: 'incomplete',
|
||||
output: [],
|
||||
}, 'gpt-test')).toMatchObject({
|
||||
finishReason: 'stop',
|
||||
});
|
||||
|
||||
expect(normalizeUpstreamFinalResponse({
|
||||
id: 'resp_final_length',
|
||||
object: 'response',
|
||||
status: 'incomplete',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
output: [],
|
||||
}, 'gpt-test')).toMatchObject({
|
||||
finishReason: 'length',
|
||||
});
|
||||
|
||||
expect(normalizeUpstreamFinalResponse({
|
||||
id: 'resp_final_failed',
|
||||
object: 'response',
|
||||
status: 'failed',
|
||||
output: [],
|
||||
}, 'gpt-test')).toMatchObject({
|
||||
finishReason: 'stop',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertClaudeRequestToOpenAiBody', () => {
|
||||
|
||||
@@ -493,6 +493,29 @@ function parseResponsesReasoning(payload: Record<string, unknown>): {
|
||||
};
|
||||
}
|
||||
|
||||
function responsesStatusToChatFinishReason(
|
||||
status: unknown,
|
||||
incompleteDetails: unknown,
|
||||
hasToolCalls: boolean,
|
||||
): string {
|
||||
const normalizedStatus = asTrimmedString(status).toLowerCase();
|
||||
if (normalizedStatus === 'completed') {
|
||||
return hasToolCalls ? 'tool_calls' : 'stop';
|
||||
}
|
||||
if (normalizedStatus === 'incomplete') {
|
||||
const reason = (
|
||||
isRecord(incompleteDetails)
|
||||
? asTrimmedString(incompleteDetails.reason).toLowerCase()
|
||||
: ''
|
||||
);
|
||||
return reason === 'max_output_tokens' ? 'length' : 'stop';
|
||||
}
|
||||
if (normalizedStatus === 'failed') {
|
||||
return 'stop';
|
||||
}
|
||||
return hasToolCalls ? 'tool_calls' : 'stop';
|
||||
}
|
||||
|
||||
function unwrapTerminalResponsesEnvelope(
|
||||
payload: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
@@ -1367,9 +1390,11 @@ export function normalizeUpstreamFinalResponse(
|
||||
content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText),
|
||||
reasoningContent: responsesReasoning.reasoningContent,
|
||||
...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}),
|
||||
finishReason: toolCalls.length > 0
|
||||
? 'tool_calls'
|
||||
: (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'),
|
||||
finishReason: responsesStatusToChatFinishReason(
|
||||
payload.status ?? payload.finish_reason,
|
||||
payload.incomplete_details,
|
||||
toolCalls.length > 0,
|
||||
),
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
@@ -1541,13 +1566,24 @@ export function normalizeUpstreamStreamEvent(
|
||||
return { role: 'assistant' };
|
||||
}
|
||||
|
||||
if (type === 'response.failed' || type === 'response.incomplete' || type === 'error') {
|
||||
if (type === 'response.incomplete' || type === 'response.failed' || type === 'error') {
|
||||
const responsePayload = isRecord((payload as any).response) ? (payload as any).response : null;
|
||||
const finishReason = normalizeStopReason(
|
||||
(responsePayload as any)?.status
|
||||
?? (payload as any).status
|
||||
?? (payload as any).type,
|
||||
) || 'error';
|
||||
let finishReason: string;
|
||||
if (type === 'response.incomplete') {
|
||||
finishReason = responsesStatusToChatFinishReason(
|
||||
'incomplete',
|
||||
(responsePayload as any)?.incomplete_details ?? (payload as any).incomplete_details,
|
||||
false,
|
||||
);
|
||||
} else if (type === 'response.failed') {
|
||||
finishReason = responsesStatusToChatFinishReason('failed', null, false);
|
||||
} else {
|
||||
finishReason = normalizeStopReason(
|
||||
(responsePayload as any)?.status
|
||||
?? (payload as any).status
|
||||
?? (payload as any).type,
|
||||
) || 'error';
|
||||
}
|
||||
return {
|
||||
finishReason,
|
||||
done: true,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasEndpointMismatchHint,
|
||||
inferRequiredEndpointFromProtocolError,
|
||||
inferSuggestedEndpointFromUpstreamError,
|
||||
promoteRequiredEndpointCandidateAfterProtocolError,
|
||||
} from './endpointCompatibility.js';
|
||||
|
||||
describe('inferRequiredEndpointFromProtocolError', () => {
|
||||
it('recognizes messages-required protocol errors', () => {
|
||||
expect(inferRequiredEndpointFromProtocolError('messages is required')).toBe('messages');
|
||||
expect(inferRequiredEndpointFromProtocolError('{"error":{"message":"messages is required"}}')).toBe('messages');
|
||||
});
|
||||
|
||||
it('recognizes responses-input-required protocol errors', () => {
|
||||
expect(inferRequiredEndpointFromProtocolError('input is required')).toBe('responses');
|
||||
expect(inferRequiredEndpointFromProtocolError('{"error":{"message":"input is required"}}')).toBe('responses');
|
||||
});
|
||||
|
||||
it('ignores unrelated protocol errors', () => {
|
||||
expect(inferRequiredEndpointFromProtocolError('unsupported endpoint')).toBeNull();
|
||||
expect(inferRequiredEndpointFromProtocolError('')).toBeNull();
|
||||
expect(inferRequiredEndpointFromProtocolError(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferSuggestedEndpointFromUpstreamError', () => {
|
||||
it('prefers required endpoints over generic path mentions', () => {
|
||||
expect(inferSuggestedEndpointFromUpstreamError('input is required for /v1/chat/completions')).toBe('responses');
|
||||
});
|
||||
|
||||
it('infers suggested endpoints from explicit upstream endpoint mentions', () => {
|
||||
expect(inferSuggestedEndpointFromUpstreamError('Unsupported endpoint /v1/messages')).toBe('messages');
|
||||
expect(inferSuggestedEndpointFromUpstreamError('POST /v1/responses is not supported')).toBe('responses');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasEndpointMismatchHint', () => {
|
||||
it('recognizes endpoint mismatch vocabulary from raw or parsed errors', () => {
|
||||
expect(hasEndpointMismatchHint('Unsupported endpoint /v1/messages')).toBe(true);
|
||||
expect(hasEndpointMismatchHint('{"error":{"message":"Unknown endpoint /v1/responses"}}')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores generic upstream errors without endpoint hints', () => {
|
||||
expect(hasEndpointMismatchHint('{"error":{"type":"upstream_error","message":"Upstream request failed"}}')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promoteRequiredEndpointCandidateAfterProtocolError', () => {
|
||||
it('promotes the required endpoint to the next slot when it appears later in the order', () => {
|
||||
const candidates: Array<'chat' | 'messages' | 'responses'> = ['chat', 'messages', 'responses'];
|
||||
|
||||
promoteRequiredEndpointCandidateAfterProtocolError(candidates, {
|
||||
currentEndpoint: 'chat',
|
||||
upstreamErrorText: 'input is required',
|
||||
});
|
||||
|
||||
expect(candidates).toEqual(['chat', 'responses', 'messages']);
|
||||
});
|
||||
|
||||
it('does nothing when the required endpoint is already next or missing', () => {
|
||||
const alreadyNext: Array<'chat' | 'messages' | 'responses'> = ['chat', 'messages', 'responses'];
|
||||
promoteRequiredEndpointCandidateAfterProtocolError(alreadyNext, {
|
||||
currentEndpoint: 'chat',
|
||||
upstreamErrorText: 'messages is required',
|
||||
});
|
||||
expect(alreadyNext).toEqual(['chat', 'messages', 'responses']);
|
||||
|
||||
const missingTarget: Array<'chat' | 'messages' | 'responses'> = ['chat', 'messages'];
|
||||
promoteRequiredEndpointCandidateAfterProtocolError(missingTarget, {
|
||||
currentEndpoint: 'chat',
|
||||
upstreamErrorText: 'input is required',
|
||||
});
|
||||
expect(missingTarget).toEqual(['chat', 'messages']);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,13 @@ import type { DownstreamFormat } from './normalized.js';
|
||||
export type CompatibilityEndpoint = 'chat' | 'messages' | 'responses';
|
||||
export type CompatibilityEndpointPreference = DownstreamFormat | 'responses';
|
||||
|
||||
type ParsedEndpointErrorShape = {
|
||||
code: string;
|
||||
message: string;
|
||||
text: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type PreferResponsesAfterLegacyChatErrorInput = {
|
||||
status: number;
|
||||
upstreamErrorText?: string | null;
|
||||
@@ -56,6 +63,46 @@ function normalizeHeaderMap(headers: Record<string, string>): Record<string, str
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseEndpointErrorShape(upstreamErrorText?: string | null): ParsedEndpointErrorShape {
|
||||
const text = (upstreamErrorText || '').toLowerCase();
|
||||
if (!text) {
|
||||
return {
|
||||
code: '',
|
||||
message: '',
|
||||
text: '',
|
||||
type: '',
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
code: asTrimmedString(error.code).toLowerCase(),
|
||||
message: asTrimmedString(error.message).toLowerCase(),
|
||||
text,
|
||||
type: asTrimmedString(error.type).toLowerCase(),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
code: '',
|
||||
message: '',
|
||||
text,
|
||||
type: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function inferEndpointMentionFromText(text: string): CompatibilityEndpoint | null {
|
||||
if (!text) return null;
|
||||
if (text.includes('/v1/responses') || /\bresponses\b/.test(text)) return 'responses';
|
||||
if (text.includes('/v1/messages') || /\bmessages\b/.test(text)) return 'messages';
|
||||
if (text.includes('/v1/chat/completions') || /\bchat(?:\/completions)?\b/.test(text)) return 'chat';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildMinimalJsonHeadersForCompatibility(input: {
|
||||
headers: Record<string, string>;
|
||||
endpoint: CompatibilityEndpoint;
|
||||
@@ -108,6 +155,68 @@ export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?
|
||||
);
|
||||
}
|
||||
|
||||
export function inferRequiredEndpointFromProtocolError(
|
||||
upstreamErrorText?: string | null,
|
||||
): CompatibilityEndpoint | null {
|
||||
const parsed = parseEndpointErrorShape(upstreamErrorText);
|
||||
const combined = `${parsed.text}\n${parsed.message}`;
|
||||
if (!combined.trim()) return null;
|
||||
if (/messages\s+is\s+required/i.test(combined)) return 'messages';
|
||||
if (/input\s+is\s+required/i.test(combined)) return 'responses';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inferSuggestedEndpointFromUpstreamError(
|
||||
upstreamErrorText?: string | null,
|
||||
): CompatibilityEndpoint | null {
|
||||
const requiredEndpoint = inferRequiredEndpointFromProtocolError(upstreamErrorText);
|
||||
if (requiredEndpoint) return requiredEndpoint;
|
||||
|
||||
const parsed = parseEndpointErrorShape(upstreamErrorText);
|
||||
return (
|
||||
inferEndpointMentionFromText(parsed.message)
|
||||
|| inferEndpointMentionFromText(parsed.text)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasEndpointMismatchHint(upstreamErrorText?: string | null): boolean {
|
||||
const parsed = parseEndpointErrorShape(upstreamErrorText);
|
||||
if (!parsed.text) return false;
|
||||
|
||||
const phrases = [
|
||||
'not found',
|
||||
'unknown endpoint',
|
||||
'unsupported endpoint',
|
||||
'unsupported path',
|
||||
'unrecognized request url',
|
||||
'no route matched',
|
||||
'does not exist',
|
||||
'invalid url',
|
||||
];
|
||||
return phrases.some((phrase) => (
|
||||
parsed.text.includes(phrase) || parsed.message.includes(phrase)
|
||||
)) || inferSuggestedEndpointFromUpstreamError(upstreamErrorText) !== null;
|
||||
}
|
||||
|
||||
export function promoteRequiredEndpointCandidateAfterProtocolError(
|
||||
endpointCandidates: CompatibilityEndpoint[],
|
||||
input: {
|
||||
currentEndpoint?: CompatibilityEndpoint | null;
|
||||
upstreamErrorText?: string | null;
|
||||
},
|
||||
): void {
|
||||
const currentEndpoint = input.currentEndpoint ?? null;
|
||||
const requiredEndpoint = inferRequiredEndpointFromProtocolError(input.upstreamErrorText);
|
||||
if (!currentEndpoint || !requiredEndpoint || currentEndpoint === requiredEndpoint) return;
|
||||
|
||||
const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === currentEndpoint);
|
||||
const requiredIndex = endpointCandidates.indexOf(requiredEndpoint);
|
||||
if (currentIndex < 0 || requiredIndex < 0 || requiredIndex <= currentIndex + 1) return;
|
||||
|
||||
endpointCandidates.splice(requiredIndex, 1);
|
||||
endpointCandidates.splice(currentIndex + 1, 0, requiredEndpoint);
|
||||
}
|
||||
|
||||
export function shouldPreferResponsesAfterLegacyChatError(
|
||||
input: PreferResponsesAfterLegacyChatErrorInput,
|
||||
): boolean {
|
||||
@@ -150,26 +259,11 @@ export function promoteResponsesCandidateAfterLegacyChatError(
|
||||
|
||||
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
|
||||
if (status < 400) return false;
|
||||
const text = (upstreamErrorText || '').toLowerCase();
|
||||
const parsed = parseEndpointErrorShape(upstreamErrorText);
|
||||
const text = parsed.text;
|
||||
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 = '';
|
||||
}
|
||||
const endpointMismatchHint = hasEndpointMismatchHint(upstreamErrorText);
|
||||
|
||||
return (
|
||||
isEndpointDispatchDeniedError(status, upstreamErrorText)
|
||||
@@ -181,8 +275,14 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
|
||||
|| 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('openai_error')
|
||||
&& endpointMismatchHint
|
||||
)
|
||||
|| (
|
||||
text.includes('upstream_error')
|
||||
&& endpointMismatchHint
|
||||
)
|
||||
|| text.includes('bad_response_status_code')
|
||||
|| text.includes('unsupported media type')
|
||||
|| text.includes("only 'application/json' is allowed")
|
||||
@@ -191,38 +291,56 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
|
||||
|| 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')
|
||||
|| parsed.code === 'convert_request_failed'
|
||||
|| parsed.code === 'not_found'
|
||||
|| parsed.code === 'endpoint_not_found'
|
||||
|| parsed.code === 'unknown_endpoint'
|
||||
|| parsed.code === 'unsupported_endpoint'
|
||||
|| parsed.code === 'bad_response_status_code'
|
||||
|| (
|
||||
parsed.code === 'openai_error'
|
||||
&& endpointMismatchHint
|
||||
)
|
||||
|| (
|
||||
parsed.code === 'upstream_error'
|
||||
&& endpointMismatchHint
|
||||
)
|
||||
|| parsed.type === 'not_found_error'
|
||||
|| parsed.type === 'invalid_request_error'
|
||||
|| parsed.type === 'unsupported_endpoint'
|
||||
|| parsed.type === 'unsupported_path'
|
||||
|| parsed.type === 'bad_response_status_code'
|
||||
|| (
|
||||
parsed.type === 'openai_error'
|
||||
&& endpointMismatchHint
|
||||
)
|
||||
|| (
|
||||
parsed.type === 'upstream_error'
|
||||
&& endpointMismatchHint
|
||||
)
|
||||
|| parsed.message.includes('unknown endpoint')
|
||||
|| parsed.message.includes('unsupported endpoint')
|
||||
|| parsed.message.includes('unsupported path')
|
||||
|| parsed.message.includes('unrecognized request url')
|
||||
|| parsed.message.includes('no route matched')
|
||||
|| parsed.message.includes('does not exist')
|
||||
|| parsed.message.includes('bad_response_status_code')
|
||||
|| (
|
||||
parsed.message === 'openai_error'
|
||||
&& endpointMismatchHint
|
||||
)
|
||||
|| (
|
||||
parsed.message === 'upstream_error'
|
||||
&& endpointMismatchHint
|
||||
)
|
||||
|| parsed.message.includes('unsupported media type')
|
||||
|| parsed.message.includes("only 'application/json' is allowed")
|
||||
|| parsed.message.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'))
|
||||
&& parsed.code === 'invalid_request'
|
||||
&& parsed.type === 'new_api_error'
|
||||
&& (parsed.message.includes('claude code cli') || text.includes('claude code cli'))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user