forked from boos/BoosAPI
Add comprehensive tests for Anthropics and Gemini transformers
- Implement tests for anthropicMessagesTransformer including serialization and event handling in stream.test.ts. - Add usage extraction tests for anthropicMessagesUsage in usage.test.ts. - Create extensive tests for Gemini generate content aggregation and outbound usage in aggregator-outbound-usage.test.ts. - Introduce tests for Gemini stream handling and payload parsing in stream.test.ts. - Enhance OpenAI response serialization tests to ensure proper mapping of usage details and preservation of annotations in outbound.test.ts.
This commit is contained in:
@@ -14,16 +14,44 @@ describe('proxy route architecture boundaries', () => {
|
|||||||
});
|
});
|
||||||
it('keeps anthropic-specific stream orchestration out of chat route', () => {
|
it('keeps anthropic-specific stream orchestration out of chat route', () => {
|
||||||
const source = readSource('./chat.ts');
|
const source = readSource('./chat.ts');
|
||||||
expect(source).not.toContain('function syncClaudeStreamStateFromRawEvent(');
|
expect(source).not.toContain('serializeAnthropicRawSseEvent');
|
||||||
expect(source).not.toContain('function serializeRawSseEvent(');
|
expect(source).not.toContain('syncAnthropicRawStreamStateFromEvent');
|
||||||
expect(source).not.toContain('function isClaudeSseEventName(');
|
expect(source).not.toContain('isAnthropicRawSseEventName');
|
||||||
|
expect(source).not.toContain('serializeAnthropicFinalAsStream');
|
||||||
|
expect(source).not.toContain('function shouldRetryClaudeMessagesWithNormalizedBody(');
|
||||||
|
expect(source).not.toContain('const emitNormalizedFinalAsStream =');
|
||||||
|
expect(source).not.toContain("from './protocolCompat.js'");
|
||||||
|
expect(source).not.toContain("from './chatStreamCompat.js'");
|
||||||
|
expect(source).not.toContain('const promoteResponsesCandidate =');
|
||||||
|
expect(source).not.toContain('shouldRetryClaudeMessagesWithNormalizedBody(');
|
||||||
|
expect(source).not.toContain('buildOpenAiSyntheticFinalStream(');
|
||||||
|
expect(source).toContain('anthropicMessagesTransformer.consumeSseEventBlock(');
|
||||||
|
expect(source).toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream(');
|
||||||
|
expect(source).toContain('openAiChatTransformer.serializeUpstreamFinalAsStream(');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps responses protocol assembly out of responses route', () => {
|
it('keeps responses protocol assembly out of responses route', () => {
|
||||||
const source = readSource('./responses.ts');
|
const source = readSource('./responses.ts');
|
||||||
expect(source).not.toContain('function toResponsesPayload(');
|
expect(source).not.toContain('function toResponsesPayload(');
|
||||||
expect(source).not.toContain('function createResponsesStreamState(');
|
expect(source).not.toContain('function createResponsesStreamState(');
|
||||||
expect(source).not.toContain('function convertResponsesBodyToOpenAiBody(');
|
expect(source).not.toContain("from '../../transformers/openai/responses/conversion.js'");
|
||||||
|
expect(source).not.toContain("from '../../transformers/openai/responses/outbound.js'");
|
||||||
|
expect(source).not.toContain("from '../../transformers/openai/responses/aggregator.js'");
|
||||||
|
expect(source).not.toContain('function buildResponsesCompatibilityBodies(');
|
||||||
|
expect(source).not.toContain('function buildResponsesCompatibilityHeaderCandidates(');
|
||||||
|
expect(source).not.toContain('function shouldRetryResponsesCompatibility(');
|
||||||
|
expect(source).not.toContain("from './protocolCompat.js'");
|
||||||
|
expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses(');
|
||||||
|
expect(source).not.toContain('function normalizeText(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryBodies(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.compatibility.shouldRetry(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.aggregator.createState(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.aggregator.serialize(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.aggregator.complete(');
|
||||||
|
expect(source).toContain('openAiResponsesTransformer.outbound.serializeFinal(');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes normalizeContentText from upstream endpoint routing', () => {
|
it('removes normalizeContentText from upstream endpoint routing', () => {
|
||||||
@@ -34,8 +62,14 @@ describe('proxy route architecture boundaries', () => {
|
|||||||
|
|
||||||
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
||||||
const source = readSource('./gemini.ts');
|
const source = readSource('./gemini.ts');
|
||||||
expect(source).toContain('serializeAggregateResponse');
|
expect(source).not.toContain('outbound.serializeAggregateResponse(');
|
||||||
expect(source).toContain('parseSsePayloads');
|
expect(source).not.toContain('aggregator.apply(');
|
||||||
expect(source).toContain('aggregator.apply(aggregateState, parsed)');
|
expect(source).not.toContain('stream.serializeAggregateSsePayload(');
|
||||||
|
expect(source).not.toContain('stream.serializeAggregateJsonPayload(');
|
||||||
|
expect(source).not.toContain('stream.applyJsonPayloadToAggregate(');
|
||||||
|
expect(source).not.toContain('stream.parseSsePayloads(');
|
||||||
|
expect(source).toContain('stream.consumeUpstreamSseBuffer(');
|
||||||
|
expect(source).toContain('stream.serializeUpstreamJsonPayload(');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -565,8 +565,15 @@ describe('chat proxy stream behavior', () => {
|
|||||||
|
|
||||||
expect(Array.isArray(firstBody.messages)).toBe(true);
|
expect(Array.isArray(firstBody.messages)).toBe(true);
|
||||||
expect(Array.isArray(secondBody.messages)).toBe(true);
|
expect(Array.isArray(secondBody.messages)).toBe(true);
|
||||||
expect(typeof secondBody.messages[0]?.content).toBe('string');
|
expect(Array.isArray(secondBody.messages[0]?.content)).toBe(true);
|
||||||
expect(secondBody.messages[0]?.content).toContain('hello');
|
expect(secondBody.messages[0]?.content).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'text',
|
||||||
|
text: expect.stringContaining('hello'),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('downgrades to next endpoint when normalized Claude fallback still returns messages is required', async () => {
|
it('downgrades to next endpoint when normalized Claude fallback still returns messages is required', async () => {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
isEndpointDispatchDeniedError,
|
isEndpointDispatchDeniedError,
|
||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
isUnsupportedMediaTypeError,
|
isUnsupportedMediaTypeError,
|
||||||
|
promoteResponsesCandidateAfterLegacyChatError,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
shouldPreferResponsesAfterLegacyChatError,
|
|
||||||
} from './upstreamEndpoint.js';
|
} from './upstreamEndpoint.js';
|
||||||
import {
|
import {
|
||||||
ensureModelAllowedForDownstreamKey,
|
ensureModelAllowedForDownstreamKey,
|
||||||
@@ -29,30 +29,9 @@ import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
|||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||||
import {
|
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
|
||||||
anthropicMessagesTransformer,
|
|
||||||
isAnthropicRawSseEventName,
|
|
||||||
serializeAnthropicFinalAsStream,
|
|
||||||
serializeAnthropicRawSseEvent,
|
|
||||||
syncAnthropicRawStreamStateFromEvent,
|
|
||||||
} from '../../transformers/anthropic/messages/index.js';
|
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
function shouldRetryClaudeMessagesWithNormalizedBody(
|
|
||||||
downstreamFormat: DownstreamFormat,
|
|
||||||
endpointPath: string,
|
|
||||||
status: number,
|
|
||||||
upstreamErrorText: string,
|
|
||||||
): boolean {
|
|
||||||
if (downstreamFormat !== 'claude') return false;
|
|
||||||
if (!endpointPath.includes('/v1/messages')) return false;
|
|
||||||
if (status < 400 || status >= 500) return false;
|
|
||||||
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMessagesRequiredError(upstreamErrorText: string): boolean {
|
|
||||||
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
@@ -125,27 +104,6 @@ async function handleChatProxyRequest(
|
|||||||
];
|
];
|
||||||
let startTime = Date.now();
|
let startTime = Date.now();
|
||||||
|
|
||||||
const promoteResponsesCandidate = (currentEndpoint: string | null | undefined, upstreamErrorText?: string | null, status = 0) => {
|
|
||||||
if (!shouldPreferResponsesAfterLegacyChatError({
|
|
||||||
status,
|
|
||||||
upstreamErrorText,
|
|
||||||
downstreamFormat,
|
|
||||||
sitePlatform: selected.site.platform,
|
|
||||||
modelName,
|
|
||||||
requestedModelHint: requestedModel,
|
|
||||||
currentEndpoint: currentEndpoint as any,
|
|
||||||
})) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === currentEndpoint);
|
|
||||||
const responsesIndex = endpointCandidates.indexOf('responses');
|
|
||||||
if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return;
|
|
||||||
|
|
||||||
endpointCandidates.splice(responsesIndex, 1);
|
|
||||||
endpointCandidates.splice(currentIndex + 1, 0, 'responses');
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endpointResult = await executeEndpointFlow({
|
const endpointResult = await executeEndpointFlow({
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
@@ -172,12 +130,12 @@ async function handleChatProxyRequest(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
tryRecover: async (ctx) => {
|
tryRecover: async (ctx) => {
|
||||||
if (shouldRetryClaudeMessagesWithNormalizedBody(
|
if (anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody({
|
||||||
downstreamFormat,
|
downstreamFormat,
|
||||||
ctx.request.path,
|
endpointPath: ctx.request.path,
|
||||||
ctx.response.status,
|
status: ctx.response.status,
|
||||||
ctx.rawErrText,
|
upstreamErrorText: ctx.rawErrText,
|
||||||
)) {
|
})) {
|
||||||
const normalizedClaudeRequest = buildUpstreamEndpointRequest({
|
const normalizedClaudeRequest = buildUpstreamEndpointRequest({
|
||||||
endpoint: ctx.request.endpoint,
|
endpoint: ctx.request.endpoint,
|
||||||
modelName,
|
modelName,
|
||||||
@@ -253,11 +211,19 @@ async function handleChatProxyRequest(
|
|||||||
},
|
},
|
||||||
shouldDowngrade: (ctx) => (
|
shouldDowngrade: (ctx) => (
|
||||||
(() => {
|
(() => {
|
||||||
promoteResponsesCandidate(ctx.request.endpoint, ctx.rawErrText, ctx.response.status);
|
promoteResponsesCandidateAfterLegacyChatError(endpointCandidates, {
|
||||||
|
status: ctx.response.status,
|
||||||
|
upstreamErrorText: ctx.rawErrText,
|
||||||
|
downstreamFormat,
|
||||||
|
sitePlatform: selected.site.platform,
|
||||||
|
modelName,
|
||||||
|
requestedModelHint: requestedModel,
|
||||||
|
currentEndpoint: ctx.request.endpoint,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
ctx.response.status >= 500
|
ctx.response.status >= 500
|
||||||
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
||||||
|| isMessagesRequiredError(ctx.rawErrText)
|
|| anthropicMessagesTransformer.compatibility.isMessagesRequiredError(ctx.rawErrText)
|
||||||
|| isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText)
|
|| isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText)
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
@@ -338,23 +304,6 @@ async function handleChatProxyRequest(
|
|||||||
writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
|
writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitNormalizedFinalAsStream = (upstreamData: unknown, fallbackText = '') => {
|
|
||||||
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, fallbackText);
|
|
||||||
streamContext.id = normalizedFinal.id;
|
|
||||||
streamContext.model = normalizedFinal.model;
|
|
||||||
streamContext.created = normalizedFinal.created;
|
|
||||||
|
|
||||||
if (downstreamFormat === 'openai') {
|
|
||||||
const syntheticChunks = openAiChatTransformer.buildSyntheticChunks(normalizedFinal);
|
|
||||||
for (const chunk of syntheticChunks) {
|
|
||||||
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeLines(serializeAnthropicFinalAsStream(normalizedFinal, streamContext, claudeContext));
|
|
||||||
};
|
|
||||||
|
|
||||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||||
if (!upstreamContentType.includes('text/event-stream')) {
|
if (!upstreamContentType.includes('text/event-stream')) {
|
||||||
const fallbackText = await upstream.text();
|
const fallbackText = await upstream.text();
|
||||||
@@ -366,7 +315,25 @@ async function handleChatProxyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
|
||||||
emitNormalizedFinalAsStream(fallbackData, fallbackText);
|
if (downstreamFormat === 'openai') {
|
||||||
|
const syntheticLines = openAiChatTransformer.serializeUpstreamFinalAsStream(
|
||||||
|
fallbackData,
|
||||||
|
modelName,
|
||||||
|
fallbackText,
|
||||||
|
streamContext,
|
||||||
|
);
|
||||||
|
writeLines(syntheticLines);
|
||||||
|
} else {
|
||||||
|
writeLines(
|
||||||
|
anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
|
||||||
|
fallbackData,
|
||||||
|
modelName,
|
||||||
|
fallbackText,
|
||||||
|
streamContext,
|
||||||
|
claudeContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
writeDone();
|
writeDone();
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
|
|
||||||
@@ -437,37 +404,37 @@ async function handleChatProxyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let parsedPayload: unknown = null;
|
let parsedPayload: unknown = null;
|
||||||
try {
|
|
||||||
parsedPayload = JSON.parse(eventBlock.data);
|
|
||||||
} catch {
|
|
||||||
parsedPayload = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
|
||||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
|
|
||||||
if (downstreamFormat === 'claude') {
|
if (downstreamFormat === 'claude') {
|
||||||
const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string')
|
const consumed = anthropicMessagesTransformer.consumeSseEventBlock(
|
||||||
? parsedPayload.type
|
eventBlock,
|
||||||
: '';
|
|
||||||
const claudeEventName = isAnthropicRawSseEventName(eventBlock.event)
|
|
||||||
? eventBlock.event
|
|
||||||
: (isAnthropicRawSseEventName(payloadType) ? payloadType : '');
|
|
||||||
|
|
||||||
if (claudeEventName) {
|
|
||||||
syncAnthropicRawStreamStateFromEvent(
|
|
||||||
claudeEventName,
|
|
||||||
parsedPayload,
|
|
||||||
streamContext,
|
streamContext,
|
||||||
claudeContext,
|
claudeContext,
|
||||||
|
modelName,
|
||||||
);
|
);
|
||||||
reply.raw.write(serializeAnthropicRawSseEvent(claudeEventName, eventBlock.data));
|
parsedPayload = consumed.parsedPayload;
|
||||||
if (claudeContext.doneSent) {
|
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||||
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
|
||||||
|
}
|
||||||
|
if (consumed.handled) {
|
||||||
|
writeLines(consumed.lines);
|
||||||
|
if (consumed.done) {
|
||||||
shouldTerminateEarly = true;
|
shouldTerminateEarly = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
parsedPayload = JSON.parse(eventBlock.data);
|
||||||
|
} catch {
|
||||||
|
parsedPayload = null;
|
||||||
}
|
}
|
||||||
|
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||||
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||||
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
|
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
|
||||||
writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
|
writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
|
||||||
if (downstreamFormat === 'claude' && claudeContext.doneSent) {
|
if (downstreamFormat === 'claude' && claudeContext.doneSent) {
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ vi.mock('../../services/downstreamApiKeyService.js', () => ({
|
|||||||
consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args),
|
consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function parseSsePayloads(body: string): Array<Record<string, unknown>> {
|
||||||
|
return body
|
||||||
|
.split('\n\n')
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((block) => block
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith('data:'))
|
||||||
|
.map((line) => line.slice(5).trimStart())
|
||||||
|
.join('\n'),
|
||||||
|
)
|
||||||
|
.filter((data) => data && data !== '[DONE]')
|
||||||
|
.map((data) => JSON.parse(data) as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
describe('gemini native proxy routes', () => {
|
describe('gemini native proxy routes', () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
|
|
||||||
@@ -301,7 +316,7 @@ describe('gemini native proxy routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aggregates non-sse json-array streaming payloads into a final Gemini response', async () => {
|
it('keeps non-sse json-array streaming payloads on the wire as chunk responses', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify([
|
fetchMock.mockResolvedValue(new Response(JSON.stringify([
|
||||||
{
|
{
|
||||||
candidates: [
|
candidates: [
|
||||||
@@ -348,7 +363,8 @@ describe('gemini native proxy routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({
|
expect(response.json()).toEqual([
|
||||||
|
{
|
||||||
responseId: '',
|
responseId: '',
|
||||||
modelVersion: '',
|
modelVersion: '',
|
||||||
candidates: [
|
candidates: [
|
||||||
@@ -357,12 +373,23 @@ describe('gemini native proxy routes', () => {
|
|||||||
finishReason: 'STOP',
|
finishReason: 'STOP',
|
||||||
content: {
|
content: {
|
||||||
role: 'model',
|
role: 'model',
|
||||||
parts: [
|
parts: [{ text: 'first' }],
|
||||||
{ text: 'first' },
|
|
||||||
{ text: 'second', thoughtSignature: 'sig-1' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
groundingMetadata: { source: 'web' },
|
groundingMetadata: { source: 'web' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseId: '',
|
||||||
|
modelVersion: '',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'second', thoughtSignature: 'sig-1' }],
|
||||||
|
},
|
||||||
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -373,7 +400,8 @@ describe('gemini native proxy routes', () => {
|
|||||||
cachedContentTokenCount: 2,
|
cachedContentTokenCount: 2,
|
||||||
thoughtsTokenCount: 3,
|
thoughtsTokenCount: 3,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => {
|
it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => {
|
||||||
@@ -428,12 +456,12 @@ describe('gemini native proxy routes', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
thinkingConfig: { thinkingLevel: 'high' },
|
thinkingConfig: { thinkingLevel: 'high', includeThoughts: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aggregates SSE payloads through the transformer instead of raw passthrough', async () => {
|
it('streams SSE payloads as normalized chunk events instead of cumulative aggregate snapshots', async () => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
@@ -466,12 +494,53 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
expect(response.body).toContain('data: {"responseId":"resp-sse"');
|
const events = parseSsePayloads(response.body);
|
||||||
expect(response.body).toContain('"groundingMetadata":{"source":"web"}');
|
|
||||||
expect(response.body).toContain('"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]}');
|
expect(events).toHaveLength(2);
|
||||||
expect(response.body).toContain('"cachedContentTokenCount":2');
|
expect(events[0]).toMatchObject({
|
||||||
expect(response.body).toContain('"thoughtsTokenCount":3');
|
responseId: 'resp-sse',
|
||||||
expect(response.body).toContain('"parts":[{"text":"first"},{"text":"second","thoughtSignature":"sig-1"}]');
|
modelVersion: 'gemini-2.5-flash',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'first' }],
|
||||||
|
},
|
||||||
|
groundingMetadata: { source: 'web' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(events[0]).not.toMatchObject({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
citationMetadata: expect.anything(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(events[1]).toMatchObject({
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 11,
|
||||||
|
candidatesTokenCount: 6,
|
||||||
|
totalTokenCount: 17,
|
||||||
|
cachedContentTokenCount: 2,
|
||||||
|
thoughtsTokenCount: 3,
|
||||||
|
},
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'second', thoughtSignature: 'sig-1' }],
|
||||||
|
},
|
||||||
|
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(events[1]).not.toMatchObject({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
groundingMetadata: expect.anything(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
expect(response.body).not.toContain('\r\n\r\n');
|
expect(response.body).not.toContain('\r\n\r\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => {
|
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const apiVersion = resolveGeminiApiVersion(request);
|
const apiVersion = resolveGeminiApiVersion(request);
|
||||||
const modelActionPath = extractGeminiModelActionPath(request, apiVersion);
|
const modelActionPath = extractGeminiModelActionPath(request, apiVersion);
|
||||||
|
const isStreamAction = modelActionPath.endsWith(':streamGenerateContent');
|
||||||
const requestedModel = modelActionPath.replace(/^models\//, '').split(':')[0].trim();
|
const requestedModel = modelActionPath.replace(/^models\//, '').split(':')[0].trim();
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
@@ -206,27 +207,23 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
if (done) break;
|
if (done) break;
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
const chunkText = decoder.decode(value, { stream: true });
|
const chunkText = decoder.decode(value, { stream: true });
|
||||||
const parsed = geminiGenerateContentTransformer.stream.parseSsePayloads(rest + chunkText);
|
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
|
||||||
rest = parsed.rest;
|
aggregateState,
|
||||||
for (const event of parsed.events) {
|
rest + chunkText,
|
||||||
geminiGenerateContentTransformer.stream.applyAggregate(aggregateState, event);
|
|
||||||
reply.raw.write(
|
|
||||||
geminiGenerateContentTransformer.stream.serializeSsePayload(
|
|
||||||
geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
rest = consumed.rest;
|
||||||
|
for (const line of consumed.lines) {
|
||||||
|
reply.raw.write(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tail = decoder.decode();
|
const tail = decoder.decode();
|
||||||
if (tail) {
|
if (tail) {
|
||||||
const parsed = geminiGenerateContentTransformer.stream.parseSsePayloads(rest + tail);
|
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
|
||||||
for (const event of parsed.events) {
|
aggregateState,
|
||||||
geminiGenerateContentTransformer.stream.applyAggregate(aggregateState, event);
|
rest + tail,
|
||||||
reply.raw.write(
|
|
||||||
geminiGenerateContentTransformer.stream.serializeSsePayload(
|
|
||||||
geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
for (const line of consumed.lines) {
|
||||||
|
reply.raw.write(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -239,19 +236,13 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
const aggregateState = geminiGenerateContentTransformer.aggregator.createState();
|
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
for (const chunk of geminiGenerateContentTransformer.stream.parseJsonArrayPayload(parsed)) {
|
|
||||||
geminiGenerateContentTransformer.aggregator.apply(aggregateState, chunk);
|
|
||||||
}
|
|
||||||
return reply.code(upstream.status).send(
|
return reply.code(upstream.status).send(
|
||||||
geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState),
|
geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
||||||
);
|
aggregateState,
|
||||||
}
|
parsed,
|
||||||
|
isStreamAction,
|
||||||
geminiGenerateContentTransformer.aggregator.apply(aggregateState, parsed);
|
),
|
||||||
return reply.code(upstream.status).send(
|
|
||||||
geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState),
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
||||||
|
|||||||
@@ -9,23 +9,13 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
|||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { type NormalizedStreamEvent } from '../../transformers/shared/normalized.js';
|
|
||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import { serializeResponsesFinalPayload } from '../../transformers/openai/responses/outbound.js';
|
|
||||||
import { convertResponsesBodyToOpenAiBody } from '../../transformers/openai/responses/conversion.js';
|
|
||||||
import {
|
|
||||||
completeResponsesStream,
|
|
||||||
createOpenAiResponsesAggregateState,
|
|
||||||
failResponsesStream,
|
|
||||||
serializeConvertedResponsesEvents,
|
|
||||||
} from '../../transformers/openai/responses/aggregator.js';
|
|
||||||
import {
|
import {
|
||||||
buildMinimalJsonHeadersForCompatibility,
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
isUnsupportedMediaTypeError,
|
isUnsupportedMediaTypeError,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
type UpstreamEndpoint,
|
|
||||||
} from './upstreamEndpoint.js';
|
} from './upstreamEndpoint.js';
|
||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
@@ -35,222 +25,10 @@ import { resolveProxyLogBilling } from './proxyBilling.js';
|
|||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
function shouldDowngradeFromChatToMessagesForResponses(
|
|
||||||
endpointPath: string,
|
|
||||||
status: number,
|
|
||||||
upstreamErrorText: string,
|
|
||||||
): boolean {
|
|
||||||
if (!endpointPath.includes('/chat/completions')) return false;
|
|
||||||
if (status < 400 || status >= 500) return false;
|
|
||||||
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUpstreamErrorShape(rawText: string): {
|
|
||||||
type: string;
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawText) as Record<string, unknown>;
|
|
||||||
const error = (parsed.error && typeof parsed.error === 'object')
|
|
||||||
? parsed.error as Record<string, unknown>
|
|
||||||
: parsed;
|
|
||||||
return {
|
|
||||||
type: typeof error.type === 'string' ? error.type.trim().toLowerCase() : '',
|
|
||||||
code: typeof error.code === 'string' ? error.code.trim().toLowerCase() : '',
|
|
||||||
message: typeof error.message === 'string' ? error.message.trim() : '',
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { type: '', code: '', message: '' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripResponsesMetadata(
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(body, 'metadata')) return null;
|
|
||||||
const next = { ...body };
|
|
||||||
delete next.metadata;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toFiniteNumber(value: unknown): number | null {
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCoreResponsesBody(
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
||||||
if (!model) return null;
|
|
||||||
if (body.input === undefined) return null;
|
|
||||||
|
|
||||||
const core: Record<string, unknown> = {
|
|
||||||
model,
|
|
||||||
input: body.input,
|
|
||||||
stream: body.stream === true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const maxOutputTokens = toFiniteNumber(body.max_output_tokens);
|
|
||||||
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
|
||||||
core.max_output_tokens = Math.trunc(maxOutputTokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
const temperature = toFiniteNumber(body.temperature);
|
|
||||||
if (temperature !== null) core.temperature = temperature;
|
|
||||||
|
|
||||||
const topP = toFiniteNumber(body.top_p);
|
|
||||||
if (topP !== null) core.top_p = topP;
|
|
||||||
|
|
||||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
|
||||||
if (instructions) core.instructions = instructions;
|
|
||||||
|
|
||||||
return core;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStrictResponsesBody(
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
||||||
if (!model) return null;
|
|
||||||
if (body.input === undefined) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
model,
|
|
||||||
input: body.input,
|
|
||||||
stream: body.stream === true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResponsesCompatibilityBodies(
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
): Record<string, unknown>[] {
|
|
||||||
const candidates: Record<string, unknown>[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
try {
|
|
||||||
const originalKey = JSON.stringify(body);
|
|
||||||
if (originalKey) seen.add(originalKey);
|
|
||||||
} catch {
|
|
||||||
// ignore non-serializable bodies
|
|
||||||
}
|
|
||||||
const push = (next: Record<string, unknown> | null) => {
|
|
||||||
if (!next) return;
|
|
||||||
let key = '';
|
|
||||||
try {
|
|
||||||
key = JSON.stringify(next);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!key || seen.has(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
candidates.push(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
push(stripResponsesMetadata(body));
|
|
||||||
push(buildCoreResponsesBody(body));
|
|
||||||
push(buildStrictResponsesBody(body));
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResponsesCompatibilityHeaderCandidates(
|
|
||||||
headers: Record<string, string>,
|
|
||||||
stream: boolean,
|
|
||||||
): Record<string, string>[] {
|
|
||||||
const candidates: Record<string, string>[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const push = (next: Record<string, string>) => {
|
|
||||||
const normalizedEntries = Object.entries(next)
|
|
||||||
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0)
|
|
||||||
.map(([key, value]) => [key.toLowerCase(), value] as const)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b));
|
|
||||||
const key = JSON.stringify(normalizedEntries);
|
|
||||||
if (!key || seen.has(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
candidates.push(Object.fromEntries(normalizedEntries));
|
|
||||||
};
|
|
||||||
|
|
||||||
push(headers);
|
|
||||||
|
|
||||||
const minimal: Record<string, string> = {};
|
|
||||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
|
||||||
const key = rawKey.toLowerCase();
|
|
||||||
if (
|
|
||||||
key === 'authorization'
|
|
||||||
|| key === 'x-api-key'
|
|
||||||
|| key === 'content-type'
|
|
||||||
|| key === 'accept'
|
|
||||||
) {
|
|
||||||
minimal[key] = rawValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!minimal['content-type']) minimal['content-type'] = 'application/json';
|
|
||||||
if (stream && !minimal.accept) minimal.accept = 'text/event-stream';
|
|
||||||
push(minimal);
|
|
||||||
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRetryResponsesCompatibility(input: {
|
|
||||||
endpoint: UpstreamEndpoint;
|
|
||||||
status: number;
|
|
||||||
rawErrText: string;
|
|
||||||
}): boolean {
|
|
||||||
if (input.endpoint !== 'responses') return false;
|
|
||||||
if (input.status !== 400) return false;
|
|
||||||
const parsedError = parseUpstreamErrorShape(input.rawErrText);
|
|
||||||
const type = parsedError.type.trim().toLowerCase();
|
|
||||||
const code = parsedError.code.trim().toLowerCase();
|
|
||||||
const message = parsedError.message.trim().toLowerCase();
|
|
||||||
const compact = `${type} ${code} ${message}`.trim();
|
|
||||||
const rawCompact = (input.rawErrText || '').toLowerCase();
|
|
||||||
|
|
||||||
// Authentication/authorization failures should not enter compatibility retries.
|
|
||||||
if (
|
|
||||||
compact.includes('invalid_api_key')
|
|
||||||
|| compact.includes('authentication')
|
|
||||||
|| compact.includes('unauthorized')
|
|
||||||
|| compact.includes('forbidden')
|
|
||||||
|| compact.includes('insufficient_quota')
|
|
||||||
|| compact.includes('rate_limit')
|
|
||||||
) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Many sub2api-compatible gateways return generic 400 for field incompatibilities.
|
|
||||||
// Retry with progressively stricter payload/header candidates to maximize compatibility.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeText(value: unknown): string {
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value
|
|
||||||
.map((item) => normalizeText(item))
|
|
||||||
.filter((item) => item.length > 0)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
if (isRecord(value)) {
|
|
||||||
if (typeof value.text === 'string') return value.text;
|
|
||||||
if (typeof value.content === 'string') return value.content;
|
|
||||||
if (typeof value.input_text === 'string') return value.input_text;
|
|
||||||
if (typeof value.output_text === 'string') return value.output_text;
|
|
||||||
if (Array.isArray(value.content)) return normalizeText(value.content);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||||
|
|
||||||
export async function responsesProxyRoute(app: FastifyInstance) {
|
export async function responsesProxyRoute(app: FastifyInstance) {
|
||||||
@@ -295,7 +73,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
excludeChannelIds.push(selected.channel.id);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
const modelName = selected.actualModel || requestedModel;
|
const modelName = selected.actualModel || requestedModel;
|
||||||
const openAiBody = convertResponsesBodyToOpenAiBody(body, modelName, isStream);
|
const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(body, modelName, isStream);
|
||||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
@@ -342,13 +120,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
tryRecover: async (ctx) => {
|
tryRecover: async (ctx) => {
|
||||||
if (shouldRetryResponsesCompatibility({
|
if (openAiResponsesTransformer.compatibility.shouldRetry({
|
||||||
endpoint: ctx.request.endpoint,
|
endpoint: ctx.request.endpoint,
|
||||||
status: ctx.response.status,
|
status: ctx.response.status,
|
||||||
rawErrText: ctx.rawErrText,
|
rawErrText: ctx.rawErrText,
|
||||||
})) {
|
})) {
|
||||||
const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body);
|
const compatibilityBodies = openAiResponsesTransformer.compatibility.buildRetryBodies(ctx.request.body);
|
||||||
const compatibilityHeaders = buildResponsesCompatibilityHeaderCandidates(
|
const compatibilityHeaders = openAiResponsesTransformer.compatibility.buildRetryHeaders(
|
||||||
ctx.request.headers,
|
ctx.request.headers,
|
||||||
isStream,
|
isStream,
|
||||||
);
|
);
|
||||||
@@ -416,7 +194,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
shouldDowngrade: (ctx) => (
|
shouldDowngrade: (ctx) => (
|
||||||
ctx.response.status >= 500
|
ctx.response.status >= 500
|
||||||
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
||||||
|| shouldDowngradeFromChatToMessagesForResponses(
|
|| openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(
|
||||||
ctx.request.path,
|
ctx.request.path,
|
||||||
ctx.response.status,
|
ctx.response.status,
|
||||||
ctx.rawErrText,
|
ctx.rawErrText,
|
||||||
@@ -492,7 +270,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
|
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
|
||||||
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
|
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
|
||||||
const responsesState = createOpenAiResponsesAggregateState(modelName);
|
const responsesState = openAiResponsesTransformer.aggregator.createState(modelName);
|
||||||
|
|
||||||
const writeLines = (lines: string[]) => {
|
const writeLines = (lines: string[]) => {
|
||||||
for (const line of lines) reply.raw.write(line);
|
for (const line of lines) reply.raw.write(line);
|
||||||
@@ -505,7 +283,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
if (passthroughResponsesStream) {
|
if (passthroughResponsesStream) {
|
||||||
reply.raw.write('data: [DONE]\n\n');
|
reply.raw.write('data: [DONE]\n\n');
|
||||||
} else if (!responsesState.completed) {
|
} else if (!responsesState.completed) {
|
||||||
writeLines(completeResponsesStream(responsesState, streamContext, parsedUsage));
|
writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -537,13 +315,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
|| payloadType === 'response.failed'
|
|| payloadType === 'response.failed'
|
||||||
);
|
);
|
||||||
if (isFailureEvent) {
|
if (isFailureEvent) {
|
||||||
writeLines(failResponsesStream(responsesState, streamContext, parsedUsage, parsedPayload));
|
writeLines(openAiResponsesTransformer.aggregator.fail(responsesState, streamContext, parsedUsage, parsedPayload));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||||
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
|
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
|
||||||
writeLines(serializeConvertedResponsesEvents({
|
writeLines(openAiResponsesTransformer.aggregator.serialize({
|
||||||
state: responsesState,
|
state: responsesState,
|
||||||
streamContext,
|
streamContext,
|
||||||
event: normalizedEvent,
|
event: normalizedEvent,
|
||||||
@@ -552,7 +330,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeLines(serializeConvertedResponsesEvents({
|
writeLines(openAiResponsesTransformer.aggregator.serialize({
|
||||||
state: responsesState,
|
state: responsesState,
|
||||||
streamContext,
|
streamContext,
|
||||||
event: { contentDelta: eventBlock.data },
|
event: { contentDelta: eventBlock.data },
|
||||||
@@ -580,7 +358,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
} finally {
|
} finally {
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
if (!passthroughResponsesStream && !responsesState.completed) {
|
if (!passthroughResponsesStream && !responsesState.completed) {
|
||||||
writeLines(completeResponsesStream(responsesState, streamContext, parsedUsage));
|
writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage));
|
||||||
}
|
}
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
}
|
}
|
||||||
@@ -632,7 +410,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
modelName,
|
modelName,
|
||||||
rawText,
|
rawText,
|
||||||
);
|
);
|
||||||
const downstreamData = serializeResponsesFinalPayload({
|
const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({
|
||||||
upstreamPayload: upstreamData,
|
upstreamPayload: upstreamData,
|
||||||
normalized,
|
normalized,
|
||||||
usage: parsedUsage,
|
usage: parsedUsage,
|
||||||
|
|||||||
@@ -578,7 +578,13 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(request.path).toBe('/v1/messages');
|
expect(request.path).toBe('/v1/messages');
|
||||||
expect(request.body.system).toBe('be careful');
|
expect(request.body.system).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'be careful',
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(request.body.messages).toEqual([
|
expect(request.body.messages).toEqual([
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -733,7 +739,13 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(request.path).toBe('/v1/messages');
|
expect(request.path).toBe('/v1/messages');
|
||||||
expect(request.body.system).toBe('system prompt');
|
expect(request.body.system).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'system prompt',
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(request.body.messages).toEqual([
|
expect(request.body.messages).toEqual([
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|||||||
@@ -611,6 +611,20 @@ export function shouldPreferResponsesAfterLegacyChatError(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function promoteResponsesCandidateAfterLegacyChatError(
|
||||||
|
endpointCandidates: UpstreamEndpoint[],
|
||||||
|
input: Parameters<typeof shouldPreferResponsesAfterLegacyChatError>[0],
|
||||||
|
): void {
|
||||||
|
if (!shouldPreferResponsesAfterLegacyChatError(input)) return;
|
||||||
|
|
||||||
|
const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint);
|
||||||
|
const responsesIndex = endpointCandidates.indexOf('responses');
|
||||||
|
if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return;
|
||||||
|
|
||||||
|
endpointCandidates.splice(responsesIndex, 1);
|
||||||
|
endpointCandidates.splice(currentIndex + 1, 0, 'responses');
|
||||||
|
}
|
||||||
|
|
||||||
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
|
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
|
||||||
if (status < 400) return false;
|
if (status < 400) return false;
|
||||||
const text = (upstreamErrorText || '').toLowerCase();
|
const text = (upstreamErrorText || '').toLowerCase();
|
||||||
|
|||||||
@@ -40,6 +40,51 @@ describe('rebuildTokenRoutesFromAvailability', () => {
|
|||||||
delete process.env.DATA_DIR;
|
delete process.env.DATA_DIR;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates an exact route with an account-direct channel for apikey model availability', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'apikey-site',
|
||||||
|
url: 'https://apikey-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'apikey-user',
|
||||||
|
accessToken: '',
|
||||||
|
apiToken: 'sk-apikey-route',
|
||||||
|
status: 'active',
|
||||||
|
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.modelAvailability).values({
|
||||||
|
accountId: account.id,
|
||||||
|
modelName: 'gpt-5.2-codex',
|
||||||
|
available: true,
|
||||||
|
latencyMs: 1200,
|
||||||
|
checkedAt: '2026-03-08T08:00:00.000Z',
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||||
|
|
||||||
|
expect(rebuild.models).toBe(1);
|
||||||
|
|
||||||
|
const route = await db.select().from(schema.tokenRoutes)
|
||||||
|
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-5.2-codex'))
|
||||||
|
.get();
|
||||||
|
expect(route).toBeDefined();
|
||||||
|
|
||||||
|
const channels = await db.select().from(schema.routeChannels)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.routeChannels.routeId, route!.id),
|
||||||
|
eq(schema.routeChannels.accountId, account.id),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
expect(channels).toHaveLength(1);
|
||||||
|
expect(channels[0]?.tokenId ?? null).toBeNull();
|
||||||
|
expect(channels[0]?.manualOverride).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('removes stale exact routes and keeps wildcard routes on rebuild', async () => {
|
it('removes stale exact routes and keeps wildcard routes on rebuild', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'site-1',
|
name: 'site-1',
|
||||||
|
|||||||
@@ -265,11 +265,35 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const modelTokens = new Map<string, Map<number, number>>();
|
const accountRows = await db.select().from(schema.modelAvailability)
|
||||||
|
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||||
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.modelAvailability.available, true),
|
||||||
|
eq(schema.accounts.status, 'active'),
|
||||||
|
eq(schema.sites.status, 'active'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
|
||||||
|
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null) => {
|
||||||
|
const modelName = (modelNameRaw || '').trim();
|
||||||
|
if (!modelName) return;
|
||||||
|
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
||||||
|
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
||||||
|
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
||||||
|
};
|
||||||
|
|
||||||
for (const row of tokenRows) {
|
for (const row of tokenRows) {
|
||||||
const modelName = row.token_model_availability.modelName;
|
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id);
|
||||||
if (!modelTokens.has(modelName)) modelTokens.set(modelName, new Map<number, number>());
|
}
|
||||||
modelTokens.get(modelName)!.set(row.account_tokens.id, row.accounts.id);
|
|
||||||
|
for (const row of accountRows) {
|
||||||
|
if (!isApiKeyConnection(row.accounts)) continue;
|
||||||
|
if (!(row.accounts.apiToken || '').trim()) continue;
|
||||||
|
addModelCandidate(row.model_availability.modelName, row.accounts.id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = await db.select().from(schema.tokenRoutes).all();
|
const routes = await db.select().from(schema.tokenRoutes).all();
|
||||||
@@ -280,7 +304,7 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
let removedChannels = 0;
|
let removedChannels = 0;
|
||||||
let removedRoutes = 0;
|
let removedRoutes = 0;
|
||||||
|
|
||||||
for (const [modelName, tokenAccountMap] of modelTokens.entries()) {
|
for (const [modelName, candidateMap] of modelCandidates.entries()) {
|
||||||
let route = routes.find((r) => r.modelPattern === modelName);
|
let route = routes.find((r) => r.modelPattern === modelName);
|
||||||
if (!route) {
|
if (!route) {
|
||||||
const inserted = await db.insert(schema.tokenRoutes).values({
|
const inserted = await db.insert(schema.tokenRoutes).values({
|
||||||
@@ -297,16 +321,19 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const routeChannels = channels.filter((channel) => channel.routeId === route.id);
|
const routeChannels = channels.filter((channel) => channel.routeId === route.id);
|
||||||
const desiredTokenIds = new Set<number>(Array.from(tokenAccountMap.keys()));
|
const desiredKeys = new Set(Array.from(candidateMap.keys()));
|
||||||
|
|
||||||
for (const [tokenId, accountId] of tokenAccountMap.entries()) {
|
for (const [candidateKey, candidate] of candidateMap.entries()) {
|
||||||
const exists = routeChannels.some((channel) => channel.tokenId === tokenId);
|
const exists = routeChannels.some((channel) => (
|
||||||
|
channel.accountId === candidate.accountId
|
||||||
|
&& (channel.tokenId ?? null) === candidate.tokenId
|
||||||
|
));
|
||||||
if (exists) continue;
|
if (exists) continue;
|
||||||
|
|
||||||
const inserted = await db.insert(schema.routeChannels).values({
|
const inserted = await db.insert(schema.routeChannels).values({
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
accountId,
|
accountId: candidate.accountId,
|
||||||
tokenId,
|
tokenId: candidate.tokenId,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
weight: 10,
|
weight: 10,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -318,16 +345,18 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
if (!created) continue;
|
if (!created) continue;
|
||||||
channels.push(created);
|
channels.push(created);
|
||||||
createdChannels++;
|
createdChannels++;
|
||||||
|
desiredKeys.add(candidateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const channel of routeChannels) {
|
for (const channel of routeChannels) {
|
||||||
if (channel.tokenId && desiredTokenIds.has(channel.tokenId)) {
|
const channelKey = `${channel.accountId}:${channel.tokenId ?? 'account'}`;
|
||||||
|
if (desiredKeys.has(channelKey)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!channel.tokenId) {
|
if (!channel.tokenId) {
|
||||||
const preferred = await getPreferredAccountToken(channel.accountId);
|
const preferred = await getPreferredAccountToken(channel.accountId);
|
||||||
if (preferred && desiredTokenIds.has(preferred.id)) {
|
if (preferred && desiredKeys.has(`${channel.accountId}:${preferred.id}`)) {
|
||||||
await db.update(schema.routeChannels)
|
await db.update(schema.routeChannels)
|
||||||
.set({ tokenId: preferred.id })
|
.set({ tokenId: preferred.id })
|
||||||
.where(eq(schema.routeChannels.id, channel.id))
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
@@ -343,7 +372,7 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestModelNames = new Set<string>(Array.from(modelTokens.keys()));
|
const latestModelNames = new Set<string>(Array.from(modelCandidates.keys()));
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
const modelPattern = (route.modelPattern || '').trim();
|
const modelPattern = (route.modelPattern || '').trim();
|
||||||
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
|
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
|
||||||
@@ -368,7 +397,7 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
invalidateTokenRouterCache();
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
models: modelTokens.size,
|
models: modelCandidates.size,
|
||||||
createdRoutes,
|
createdRoutes,
|
||||||
createdChannels,
|
createdChannels,
|
||||||
removedChannels,
|
removedChannels,
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { applyAnthropicMessagesAggregateEvent, createAnthropicMessagesAggregateState } from './aggregator.js';
|
||||||
|
|
||||||
|
describe('applyAnthropicMessagesAggregateEvent', () => {
|
||||||
|
it('buffers signature deltas until the thinking block stops', () => {
|
||||||
|
const state = createAnthropicMessagesAggregateState();
|
||||||
|
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'thinking',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'step-1' });
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
signatureDelta: 'sig-buffered',
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'step-2' });
|
||||||
|
|
||||||
|
expect(state.pendingSignature).toBe('sig-buffered');
|
||||||
|
expect((state as any).contentBlocks).toEqual([
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'step-1step-2',
|
||||||
|
signature: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
stopBlockIndex: 0,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(state.pendingSignature).toBeNull();
|
||||||
|
expect((state as any).contentBlocks).toEqual([
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'step-1step-2',
|
||||||
|
signature: 'sig-buffered',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps thinking signatures and redacted_thinking blocks in content order', () => {
|
||||||
|
const state = createAnthropicMessagesAggregateState();
|
||||||
|
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'thinking',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'internal-step' });
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
signatureDelta: 'sig-1',
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
stopBlockIndex: 0,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'redacted_thinking',
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
redactedThinkingData: 'ciphertext',
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
stopBlockIndex: 1,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect((state as any).contentBlocks).toEqual([
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'internal-step',
|
||||||
|
signature: 'sig-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'redacted_thinking',
|
||||||
|
data: 'ciphertext',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(state.blockLifecycle).toEqual([
|
||||||
|
{ kind: 'thinking', phase: 'start', index: 0 },
|
||||||
|
{ kind: 'thinking', phase: 'stop', index: 0 },
|
||||||
|
{ kind: 'redacted_thinking', phase: 'start', index: 1 },
|
||||||
|
{ kind: 'redacted_thinking', phase: 'stop', index: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates tool_use JSON on one block and preserves block order after thinking', () => {
|
||||||
|
const state = createAnthropicMessagesAggregateState();
|
||||||
|
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'thinking',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'plan' });
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
stopBlockIndex: 0,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'lookup_city',
|
||||||
|
argumentsDelta: '{"city":"par',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
argumentsDelta: 'is"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((state as any).contentBlocks).toEqual([
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'plan',
|
||||||
|
signature: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'lookup_city',
|
||||||
|
inputJson: '{"city":"paris"}',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(state.toolCalls[0]).toEqual({
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'lookup_city',
|
||||||
|
arguments: '{"city":"paris"}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
import { type NormalizedStreamEvent } from '../../shared/normalized.js';
|
import { type NormalizedStreamEvent } from '../../shared/normalized.js';
|
||||||
|
|
||||||
|
export type AnthropicAggregatedContentBlock =
|
||||||
|
| {
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'thinking';
|
||||||
|
thinking: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'redacted_thinking';
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tool_use';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
inputJson: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AnthropicStreamExtension = {
|
export type AnthropicStreamExtension = {
|
||||||
signatureDelta?: string;
|
signatureDelta?: string;
|
||||||
redactedThinkingData?: string;
|
redactedThinkingData?: string;
|
||||||
@@ -31,6 +52,11 @@ export type AnthropicMessagesAggregateState = {
|
|||||||
thinkingBlockIndex: number | null;
|
thinkingBlockIndex: number | null;
|
||||||
redactedBlockIndexes: number[];
|
redactedBlockIndexes: number[];
|
||||||
blockLifecycle: AnthropicBlockLifecycleEntry[];
|
blockLifecycle: AnthropicBlockLifecycleEntry[];
|
||||||
|
contentBlocks: AnthropicAggregatedContentBlock[];
|
||||||
|
textBlockOrder: number | null;
|
||||||
|
thinkingBlockOrder: number | null;
|
||||||
|
redactedBlockOrders: Record<number, number>;
|
||||||
|
toolBlockOrders: Record<number, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createAnthropicMessagesAggregateState(): AnthropicMessagesAggregateState {
|
export function createAnthropicMessagesAggregateState(): AnthropicMessagesAggregateState {
|
||||||
@@ -45,9 +71,75 @@ export function createAnthropicMessagesAggregateState(): AnthropicMessagesAggreg
|
|||||||
thinkingBlockIndex: null,
|
thinkingBlockIndex: null,
|
||||||
redactedBlockIndexes: [],
|
redactedBlockIndexes: [],
|
||||||
blockLifecycle: [],
|
blockLifecycle: [],
|
||||||
|
contentBlocks: [],
|
||||||
|
textBlockOrder: null,
|
||||||
|
thinkingBlockOrder: null,
|
||||||
|
redactedBlockOrders: {},
|
||||||
|
toolBlockOrders: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendContentBlock(
|
||||||
|
state: AnthropicMessagesAggregateState,
|
||||||
|
block: AnthropicAggregatedContentBlock,
|
||||||
|
): number {
|
||||||
|
state.contentBlocks.push(block);
|
||||||
|
return state.contentBlocks.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureThinkingContentBlock(
|
||||||
|
state: AnthropicMessagesAggregateState,
|
||||||
|
): Extract<AnthropicAggregatedContentBlock, { type: 'thinking' }> {
|
||||||
|
const order = state.thinkingBlockOrder;
|
||||||
|
if (order !== null && state.contentBlocks[order]?.type === 'thinking') {
|
||||||
|
return state.contentBlocks[order] as Extract<AnthropicAggregatedContentBlock, { type: 'thinking' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: Extract<AnthropicAggregatedContentBlock, { type: 'thinking' }> = {
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: '',
|
||||||
|
signature: '',
|
||||||
|
};
|
||||||
|
state.thinkingBlockOrder = appendContentBlock(state, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureToolContentBlock(
|
||||||
|
state: AnthropicMessagesAggregateState,
|
||||||
|
index: number,
|
||||||
|
): Extract<AnthropicAggregatedContentBlock, { type: 'tool_use' }> {
|
||||||
|
const existingOrder = state.toolBlockOrders[index];
|
||||||
|
if (existingOrder !== undefined && state.contentBlocks[existingOrder]?.type === 'tool_use') {
|
||||||
|
return state.contentBlocks[existingOrder] as Extract<AnthropicAggregatedContentBlock, { type: 'tool_use' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: Extract<AnthropicAggregatedContentBlock, { type: 'tool_use' }> = {
|
||||||
|
type: 'tool_use',
|
||||||
|
id: `toolu_${index}`,
|
||||||
|
name: '',
|
||||||
|
inputJson: '',
|
||||||
|
};
|
||||||
|
state.toolBlockOrders[index] = appendContentBlock(state, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRedactedContentBlock(
|
||||||
|
state: AnthropicMessagesAggregateState,
|
||||||
|
index: number,
|
||||||
|
): Extract<AnthropicAggregatedContentBlock, { type: 'redacted_thinking' }> {
|
||||||
|
const existingOrder = state.redactedBlockOrders[index];
|
||||||
|
if (existingOrder !== undefined && state.contentBlocks[existingOrder]?.type === 'redacted_thinking') {
|
||||||
|
return state.contentBlocks[existingOrder] as Extract<AnthropicAggregatedContentBlock, { type: 'redacted_thinking' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: Extract<AnthropicAggregatedContentBlock, { type: 'redacted_thinking' }> = {
|
||||||
|
type: 'redacted_thinking',
|
||||||
|
data: '',
|
||||||
|
};
|
||||||
|
state.redactedBlockOrders[index] = appendContentBlock(state, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureToolState(
|
function ensureToolState(
|
||||||
state: AnthropicMessagesAggregateState,
|
state: AnthropicMessagesAggregateState,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -62,8 +154,24 @@ function ensureToolState(
|
|||||||
return state.toolCalls[index];
|
return state.toolCalls[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySignatureToThinkingBlock(
|
||||||
|
state: AnthropicMessagesAggregateState,
|
||||||
|
signature: string,
|
||||||
|
): void {
|
||||||
|
const thinkingBlock = ensureThinkingContentBlock(state);
|
||||||
|
thinkingBlock.signature += signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferPendingSignature(
|
||||||
|
state: AnthropicMessagesAggregateState,
|
||||||
|
signature: string,
|
||||||
|
): void {
|
||||||
|
state.pendingSignature = `${state.pendingSignature || ''}${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
function flushPendingSignature(state: AnthropicMessagesAggregateState): void {
|
function flushPendingSignature(state: AnthropicMessagesAggregateState): void {
|
||||||
if (!state.pendingSignature) return;
|
if (!state.pendingSignature) return;
|
||||||
|
applySignatureToThinkingBlock(state, state.pendingSignature);
|
||||||
state.signatures.push(state.pendingSignature);
|
state.signatures.push(state.pendingSignature);
|
||||||
state.pendingSignature = null;
|
state.pendingSignature = null;
|
||||||
}
|
}
|
||||||
@@ -76,18 +184,21 @@ function recordBlockStart(
|
|||||||
state.blockLifecycle.push({ kind, phase: 'start', index });
|
state.blockLifecycle.push({ kind, phase: 'start', index });
|
||||||
if (kind === 'thinking') {
|
if (kind === 'thinking') {
|
||||||
state.thinkingBlockIndex = typeof index === 'number' ? index : state.thinkingBlockIndex;
|
state.thinkingBlockIndex = typeof index === 'number' ? index : state.thinkingBlockIndex;
|
||||||
|
ensureThinkingContentBlock(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === 'redacted_thinking' && typeof index === 'number') {
|
if (kind === 'redacted_thinking' && typeof index === 'number') {
|
||||||
state.redactedBlockIndexes.push(index);
|
state.redactedBlockIndexes.push(index);
|
||||||
|
ensureRedactedContentBlock(state, index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordBlockStop(state: AnthropicMessagesAggregateState, index: number): void {
|
function recordBlockStop(state: AnthropicMessagesAggregateState, index: number): void {
|
||||||
if (state.thinkingBlockIndex === index) {
|
if (state.thinkingBlockIndex === index) {
|
||||||
state.blockLifecycle.push({ kind: 'thinking', phase: 'stop', index });
|
state.blockLifecycle.push({ kind: 'thinking', phase: 'stop', index });
|
||||||
state.thinkingBlockIndex = null;
|
|
||||||
flushPendingSignature(state);
|
flushPendingSignature(state);
|
||||||
|
state.thinkingBlockIndex = null;
|
||||||
|
state.thinkingBlockOrder = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +206,7 @@ function recordBlockStop(state: AnthropicMessagesAggregateState, index: number):
|
|||||||
if (redactedIndex >= 0) {
|
if (redactedIndex >= 0) {
|
||||||
state.blockLifecycle.push({ kind: 'redacted_thinking', phase: 'stop', index });
|
state.blockLifecycle.push({ kind: 'redacted_thinking', phase: 'stop', index });
|
||||||
state.redactedBlockIndexes.splice(redactedIndex, 1);
|
state.redactedBlockIndexes.splice(redactedIndex, 1);
|
||||||
|
delete state.redactedBlockOrders[index];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +217,14 @@ function finalizeOpenBlocks(state: AnthropicMessagesAggregateState): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.pendingSignature) {
|
if (state.pendingSignature) {
|
||||||
|
if (state.thinkingBlockIndex === null && state.thinkingBlockOrder === null) {
|
||||||
|
state.blockLifecycle.push({ kind: 'thinking', phase: 'start' });
|
||||||
|
}
|
||||||
flushPendingSignature(state);
|
flushPendingSignature(state);
|
||||||
|
if (state.thinkingBlockIndex === null && state.thinkingBlockOrder !== null) {
|
||||||
|
state.blockLifecycle.push({ kind: 'thinking', phase: 'stop' });
|
||||||
|
state.thinkingBlockOrder = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openRedactedIndexes = [...state.redactedBlockIndexes];
|
const openRedactedIndexes = [...state.redactedBlockIndexes];
|
||||||
@@ -130,33 +249,50 @@ export function applyAnthropicMessagesAggregateEvent(
|
|||||||
|
|
||||||
if (event.reasoningDelta) {
|
if (event.reasoningDelta) {
|
||||||
state.reasoning.push(event.reasoningDelta);
|
state.reasoning.push(event.reasoningDelta);
|
||||||
|
ensureThinkingContentBlock(state).thinking += event.reasoningDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.contentDelta) {
|
if (event.contentDelta) {
|
||||||
state.text.push(event.contentDelta);
|
state.text.push(event.contentDelta);
|
||||||
|
if (state.textBlockOrder === null || state.contentBlocks[state.textBlockOrder]?.type !== 'text') {
|
||||||
|
state.textBlockOrder = appendContentBlock(state, {
|
||||||
|
type: 'text',
|
||||||
|
text: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const textBlock = state.contentBlocks[state.textBlockOrder] as Extract<AnthropicAggregatedContentBlock, { type: 'text' }>;
|
||||||
|
textBlock.text += event.contentDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(event.toolCallDeltas)) {
|
if (Array.isArray(event.toolCallDeltas)) {
|
||||||
for (const toolDelta of event.toolCallDeltas) {
|
for (const toolDelta of event.toolCallDeltas) {
|
||||||
const toolState = ensureToolState(state, toolDelta.index);
|
const toolState = ensureToolState(state, toolDelta.index);
|
||||||
|
const toolBlock = ensureToolContentBlock(state, toolDelta.index);
|
||||||
if (toolDelta.id) toolState.id = toolDelta.id;
|
if (toolDelta.id) toolState.id = toolDelta.id;
|
||||||
if (toolDelta.name) toolState.name = toolDelta.name;
|
if (toolDelta.name) toolState.name = toolDelta.name;
|
||||||
|
if (toolDelta.id) toolBlock.id = toolDelta.id;
|
||||||
|
if (toolDelta.name) toolBlock.name = toolDelta.name;
|
||||||
if (toolDelta.argumentsDelta) {
|
if (toolDelta.argumentsDelta) {
|
||||||
toolState.arguments += toolDelta.argumentsDelta;
|
toolState.arguments += toolDelta.argumentsDelta;
|
||||||
|
toolBlock.inputJson += toolDelta.argumentsDelta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.anthropic?.signatureDelta) {
|
if (event.anthropic?.signatureDelta) {
|
||||||
if (state.thinkingBlockIndex !== null) {
|
bufferPendingSignature(state, event.anthropic.signatureDelta);
|
||||||
state.signatures.push(event.anthropic.signatureDelta);
|
|
||||||
} else {
|
|
||||||
state.pendingSignature = event.anthropic.signatureDelta;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.anthropic?.redactedThinkingData) {
|
if (event.anthropic?.redactedThinkingData) {
|
||||||
state.redactedReasoning.push(event.anthropic.redactedThinkingData);
|
state.redactedReasoning.push(event.anthropic.redactedThinkingData);
|
||||||
|
const redactedIndex = (
|
||||||
|
typeof event.anthropic.startBlock?.index === 'number'
|
||||||
|
? event.anthropic.startBlock.index
|
||||||
|
: state.redactedBlockIndexes[state.redactedBlockIndexes.length - 1]
|
||||||
|
);
|
||||||
|
if (typeof redactedIndex === 'number') {
|
||||||
|
ensureRedactedContentBlock(state, redactedIndex).data = event.anthropic.redactedThinkingData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { type DownstreamFormat } from '../../shared/normalized.js';
|
||||||
|
|
||||||
|
export function shouldRetryNormalizedMessagesBody(input: {
|
||||||
|
downstreamFormat: DownstreamFormat;
|
||||||
|
endpointPath: string;
|
||||||
|
status: number;
|
||||||
|
upstreamErrorText: string;
|
||||||
|
}): boolean {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMessagesRequiredError(upstreamErrorText: string): boolean {
|
||||||
|
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
||||||
|
}
|
||||||
@@ -29,6 +29,71 @@ describe('sanitizeAnthropicMessagesBody', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes string system and message content before rebuilding cache anchors', () => {
|
||||||
|
const result = sanitizeAnthropicMessagesBody({
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
system: 'system prompt',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello from user',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.system).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'system prompt',
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.messages).toEqual([
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'hello from user',
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops output_config.effort unless thinking stays adaptive', () => {
|
||||||
|
const enabled = sanitizeAnthropicMessagesBody({
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
thinking: {
|
||||||
|
type: 'enabled',
|
||||||
|
budget_tokens: 256,
|
||||||
|
},
|
||||||
|
output_config: {
|
||||||
|
effort: 'high',
|
||||||
|
format: 'json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const adaptive = sanitizeAnthropicMessagesBody({
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
thinking: {
|
||||||
|
type: 'adaptive',
|
||||||
|
},
|
||||||
|
output_config: {
|
||||||
|
effort: 'high',
|
||||||
|
format: 'json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enabled.output_config).toEqual({
|
||||||
|
format: 'json',
|
||||||
|
});
|
||||||
|
expect(adaptive.output_config).toEqual({
|
||||||
|
effort: 'high',
|
||||||
|
format: 'json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('drops non-ephemeral cache_control markers while preserving valid tool block markers', () => {
|
it('drops non-ephemeral cache_control markers while preserving valid tool block markers', () => {
|
||||||
const result = sanitizeAnthropicMessagesBody({
|
const result = sanitizeAnthropicMessagesBody({
|
||||||
model: 'claude-opus-4-6',
|
model: 'claude-opus-4-6',
|
||||||
@@ -344,6 +409,59 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
|||||||
expect(body.thinking).toEqual({ type: 'adaptive' });
|
expect(body.thinking).toEqual({ type: 'adaptive' });
|
||||||
expect(body.output_config).toEqual({ effort: 'high' });
|
expect(body.output_config).toEqual({ effort: 'high' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rebuilds cache_control anchors at the anthropic inbound boundary', () => {
|
||||||
|
const result = anthropicMessagesInbound.parse(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
max_tokens: 512,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'lookup',
|
||||||
|
input_schema: { type: 'object' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'search',
|
||||||
|
input_schema: { type: 'object' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
system: 'system prompt',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.value?.claudeOriginalBody?.system).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'system prompt',
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.value?.claudeOriginalBody?.tools).toEqual([
|
||||||
|
{
|
||||||
|
name: 'lookup',
|
||||||
|
input_schema: { type: 'object' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'search',
|
||||||
|
input_schema: { type: 'object' },
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.value?.claudeOriginalBody?.messages).toEqual([
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'hello',
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('convertOpenAiToolChoiceToAnthropic', () => {
|
describe('convertOpenAiToolChoiceToAnthropic', () => {
|
||||||
@@ -410,6 +528,17 @@ describe('anthropicMessagesInbound', () => {
|
|||||||
|
|
||||||
expect(result.error?.statusCode).toBe(400);
|
expect(result.error?.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects string tool_choice tool without a tool name', () => {
|
||||||
|
const result = anthropicMessagesInbound.parse({
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
max_tokens: 512,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
tool_choice: 'tool',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error?.statusCode).toBe(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('anthropicMessagesTransformer.stream', () => {
|
describe('anthropicMessagesTransformer.stream', () => {
|
||||||
@@ -470,13 +599,20 @@ describe('anthropicMessagesTransformer.stream', () => {
|
|||||||
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
|
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
|
||||||
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
|
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||||
|
|
||||||
const redacted = anthropicMessagesTransformer.transformStreamEvent({
|
const redactedStart = anthropicMessagesTransformer.transformStreamEvent({
|
||||||
type: 'content_block_start',
|
type: 'content_block_start',
|
||||||
index: 0,
|
index: 0,
|
||||||
content_block: { type: 'redacted_thinking', data: 'ciphertext' },
|
content_block: { type: 'redacted_thinking', data: 'ciphertext' },
|
||||||
}, streamContext, 'claude-opus-4-6');
|
}, streamContext, 'claude-opus-4-6');
|
||||||
|
const redactedStop = anthropicMessagesTransformer.transformStreamEvent({
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: 0,
|
||||||
|
}, streamContext, 'claude-opus-4-6');
|
||||||
|
|
||||||
const serialized = anthropicMessagesTransformer.serializeStreamEvent(redacted, streamContext, downstreamContext).join('');
|
const serialized = [
|
||||||
|
...anthropicMessagesTransformer.serializeStreamEvent(redactedStart, streamContext, downstreamContext),
|
||||||
|
...anthropicMessagesTransformer.serializeStreamEvent(redactedStop, streamContext, downstreamContext),
|
||||||
|
].join('');
|
||||||
expect(serialized).toContain('"type":"redacted_thinking"');
|
expect(serialized).toContain('"type":"redacted_thinking"');
|
||||||
expect(serialized).toContain('content_block_start');
|
expect(serialized).toContain('content_block_start');
|
||||||
expect(serialized).toContain('content_block_stop');
|
expect(serialized).toContain('content_block_stop');
|
||||||
@@ -492,11 +628,14 @@ describe('extractAnthropicUsage', () => {
|
|||||||
cache_read_input_tokens: 12,
|
cache_read_input_tokens: 12,
|
||||||
cache_creation_input_tokens: 8,
|
cache_creation_input_tokens: 8,
|
||||||
},
|
},
|
||||||
})).toEqual({
|
})).toMatchObject({
|
||||||
promptTokens: 120,
|
promptTokens: 120,
|
||||||
completionTokens: 30,
|
completionTokens: 30,
|
||||||
totalTokens: 150,
|
totalTokens: 150,
|
||||||
cachedTokens: 12,
|
cachedTokens: 12,
|
||||||
|
cacheReadInputTokens: 12,
|
||||||
|
cacheCreationInputTokens: 8,
|
||||||
|
promptTokensIncludingCache: 120,
|
||||||
reasoningTokens: 0,
|
reasoningTokens: 0,
|
||||||
audioInputTokens: 0,
|
audioInputTokens: 0,
|
||||||
audioOutputTokens: 0,
|
audioOutputTokens: 0,
|
||||||
@@ -517,7 +656,7 @@ describe('extractAnthropicUsage', () => {
|
|||||||
ephemeral_1h_input_tokens: 3,
|
ephemeral_1h_input_tokens: 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})).toEqual({
|
})).toMatchObject({
|
||||||
cacheReadInputTokens: 12,
|
cacheReadInputTokens: 12,
|
||||||
cacheCreationInputTokens: 8,
|
cacheCreationInputTokens: 8,
|
||||||
ephemeral5mInputTokens: 5,
|
ephemeral5mInputTokens: 5,
|
||||||
@@ -537,7 +676,7 @@ describe('extractAnthropicUsage', () => {
|
|||||||
ephemeral_1h_input_tokens: 3,
|
ephemeral_1h_input_tokens: 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})).toEqual({
|
})).toMatchObject({
|
||||||
cacheReadInputTokens: 12,
|
cacheReadInputTokens: 12,
|
||||||
cacheCreationInputTokens: 8,
|
cacheCreationInputTokens: 8,
|
||||||
ephemeral5mInputTokens: 5,
|
ephemeral5mInputTokens: 5,
|
||||||
@@ -663,4 +802,32 @@ describe('applyAnthropicMessagesAggregateEvent', () => {
|
|||||||
]);
|
]);
|
||||||
expect(state.finishReason).toBe('stop');
|
expect(state.finishReason).toBe('stop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates a synthetic thinking lifecycle when a buffered signature is flushed on finish', () => {
|
||||||
|
const state = createAnthropicMessagesAggregateState();
|
||||||
|
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
anthropic: {
|
||||||
|
signatureDelta: 'sig-only',
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
applyAnthropicMessagesAggregateEvent(state, {
|
||||||
|
finishReason: 'stop',
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.pendingSignature).toBeNull();
|
||||||
|
expect(state.signatures).toEqual(['sig-only']);
|
||||||
|
expect(state.contentBlocks).toEqual([
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: '',
|
||||||
|
signature: 'sig-only',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(state.blockLifecycle).toEqual([
|
||||||
|
{ kind: 'thinking', phase: 'start' },
|
||||||
|
{ kind: 'thinking', phase: 'stop' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
const VALID_ANTHROPIC_TOOL_CHOICE_TYPES = new Set(['auto', 'none', 'any', 'tool']);
|
const VALID_ANTHROPIC_TOOL_CHOICE_TYPES = new Set(['auto', 'none', 'any', 'tool']);
|
||||||
const VALID_ANTHROPIC_THINKING_TYPES = new Set(['enabled', 'disabled', 'adaptive']);
|
const VALID_ANTHROPIC_THINKING_TYPES = new Set(['enabled', 'disabled', 'adaptive']);
|
||||||
const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']);
|
const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']);
|
||||||
|
const MAX_ANTHROPIC_CACHE_CONTROL_BREAKPOINTS = 4;
|
||||||
|
const ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW = 20;
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -47,18 +49,24 @@ function sanitizeCacheControl(value: unknown): Record<string, unknown> | undefin
|
|||||||
|
|
||||||
function sanitizeAnthropicOutputConfig(
|
function sanitizeAnthropicOutputConfig(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
|
options: { allowEffort?: boolean } = {},
|
||||||
): { value?: Record<string, unknown>; error?: string } {
|
): { value?: Record<string, unknown>; error?: string } {
|
||||||
if (!isRecord(value)) return {};
|
if (!isRecord(value)) return {};
|
||||||
|
const allowEffort = options.allowEffort === true;
|
||||||
|
|
||||||
const next: Record<string, unknown> = {};
|
const next: Record<string, unknown> = {};
|
||||||
if ('effort' in value) {
|
if ('effort' in value) {
|
||||||
const effort = asTrimmedString(value.effort).toLowerCase();
|
const effort = asTrimmedString(value.effort).toLowerCase();
|
||||||
if (!effort) return { value: next };
|
if (!effort) {
|
||||||
if (!VALID_ANTHROPIC_EFFORTS.has(effort)) {
|
// Ignore blank effort and continue preserving other output_config fields.
|
||||||
|
} else if (!allowEffort) {
|
||||||
|
// Ignore effort outside adaptive thinking; upstream only applies it there.
|
||||||
|
} else if (!VALID_ANTHROPIC_EFFORTS.has(effort)) {
|
||||||
return { error: 'output_config.effort must be one of: low, medium, high, max' };
|
return { error: 'output_config.effort must be one of: low, medium, high, max' };
|
||||||
}
|
} else {
|
||||||
next.effort = effort;
|
next.effort = effort;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, entry] of Object.entries(value)) {
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
if (key === 'effort') continue;
|
if (key === 'effort') continue;
|
||||||
@@ -77,6 +85,9 @@ function sanitizeAnthropicToolChoiceValue(
|
|||||||
const normalized = asTrimmedString(value).toLowerCase();
|
const normalized = asTrimmedString(value).toLowerCase();
|
||||||
if (!normalized) return {};
|
if (!normalized) return {};
|
||||||
if (normalized === 'required') return { value: { type: 'any' } };
|
if (normalized === 'required') return { value: { type: 'any' } };
|
||||||
|
if (normalized === 'tool') {
|
||||||
|
return { error: 'tool_choice.name is required when type is tool' };
|
||||||
|
}
|
||||||
if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(normalized)) {
|
if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(normalized)) {
|
||||||
return { error: 'tool_choice.type must be one of: auto, none, any, tool' };
|
return { error: 'tool_choice.type must be one of: auto, none, any, tool' };
|
||||||
}
|
}
|
||||||
@@ -359,6 +370,34 @@ function clearAnthropicCacheControls(body: Record<string, unknown>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAnthropicMessageContents(body: Record<string, unknown>) {
|
||||||
|
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||||
|
body.messages = rawMessages.map((message) => {
|
||||||
|
if (!isRecord(message) || Array.isArray(message.content)) return message;
|
||||||
|
if (typeof message.content !== 'string' || message.content.length === 0) return message;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: message.content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnthropicSystemPrompts(body: Record<string, unknown>) {
|
||||||
|
if (Array.isArray(body.system)) return;
|
||||||
|
if (typeof body.system !== 'string' || body.system.length === 0) return;
|
||||||
|
body.system = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: body.system,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function ensureStructuralAnthropicCacheControls(body: Record<string, unknown>): number {
|
function ensureStructuralAnthropicCacheControls(body: Record<string, unknown>): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
@@ -454,10 +493,12 @@ function sanitizeUnsupportedAnthropicCacheControls(body: Record<string, unknown>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
|
function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
|
||||||
|
normalizeAnthropicMessageContents(body);
|
||||||
|
normalizeAnthropicSystemPrompts(body);
|
||||||
clearAnthropicCacheControls(body);
|
clearAnthropicCacheControls(body);
|
||||||
|
|
||||||
const structuralAnchors = ensureStructuralAnthropicCacheControls(body);
|
const structuralAnchors = ensureStructuralAnthropicCacheControls(body);
|
||||||
const remaining = Math.max(0, 4 - structuralAnchors);
|
const remaining = Math.max(0, MAX_ANTHROPIC_CACHE_CONTROL_BREAKPOINTS - structuralAnchors);
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
sanitizeUnsupportedAnthropicCacheControls(body);
|
sanitizeUnsupportedAnthropicCacheControls(body);
|
||||||
return;
|
return;
|
||||||
@@ -469,7 +510,7 @@ function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const desiredMessageAnchors = refs.length >= 20 ? 2 : 1;
|
const desiredMessageAnchors = refs.length >= ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW ? 2 : 1;
|
||||||
const targetAnchors = Math.min(desiredMessageAnchors, remaining);
|
const targetAnchors = Math.min(desiredMessageAnchors, remaining);
|
||||||
const usedIndexes = new Set<number>();
|
const usedIndexes = new Set<number>();
|
||||||
|
|
||||||
@@ -482,7 +523,7 @@ function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
|
|||||||
applyAnchor(refs.length - 1);
|
applyAnchor(refs.length - 1);
|
||||||
|
|
||||||
if (targetAnchors > 1) {
|
if (targetAnchors > 1) {
|
||||||
const targetIndex = Math.max(refs.length - 1 - 20, 0);
|
const targetIndex = Math.max(refs.length - 1 - ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW, 0);
|
||||||
let chosen = -1;
|
let chosen = -1;
|
||||||
for (let index = targetIndex; index >= 0; index -= 1) {
|
for (let index = targetIndex; index >= 0; index -= 1) {
|
||||||
if (!usedIndexes.has(index)) {
|
if (!usedIndexes.has(index)) {
|
||||||
@@ -546,7 +587,12 @@ export function sanitizeAnthropicMessagesBody(
|
|||||||
delete sanitized.thinking;
|
delete sanitized.thinking;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputConfigResult = sanitizeAnthropicOutputConfig(sanitized.output_config ?? sanitized.outputConfig);
|
const allowOutputConfigEffort = isRecord(sanitized.thinking)
|
||||||
|
&& asTrimmedString(sanitized.thinking.type).toLowerCase() === 'adaptive';
|
||||||
|
const outputConfigResult = sanitizeAnthropicOutputConfig(
|
||||||
|
sanitized.output_config ?? sanitized.outputConfig,
|
||||||
|
{ allowEffort: allowOutputConfigEffort },
|
||||||
|
);
|
||||||
if (outputConfigResult.value) {
|
if (outputConfigResult.value) {
|
||||||
sanitized.output_config = outputConfigResult.value;
|
sanitized.output_config = outputConfigResult.value;
|
||||||
} else {
|
} else {
|
||||||
@@ -653,28 +699,29 @@ export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
|
|||||||
export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
|
export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
|
||||||
if (rawToolChoice === undefined) return undefined;
|
if (rawToolChoice === undefined) return undefined;
|
||||||
|
|
||||||
|
let mappedValue: unknown = rawToolChoice;
|
||||||
if (typeof rawToolChoice === 'string') {
|
if (typeof rawToolChoice === 'string') {
|
||||||
const normalized = rawToolChoice.trim().toLowerCase();
|
const normalized = rawToolChoice.trim().toLowerCase();
|
||||||
if (normalized === 'required') return { type: 'any' };
|
if (normalized === 'required') mappedValue = { type: 'any' };
|
||||||
if (normalized === 'none') return { type: 'none' };
|
else if (normalized === 'none') mappedValue = { type: 'none' };
|
||||||
if (normalized === 'auto') return { type: 'auto' };
|
else if (normalized === 'auto') mappedValue = { type: 'auto' };
|
||||||
if (normalized === 'any') return { type: 'any' };
|
else if (normalized === 'any') mappedValue = { type: 'any' };
|
||||||
return rawToolChoice;
|
else mappedValue = rawToolChoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
if (isRecord(rawToolChoice)) {
|
||||||
|
|
||||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||||
if (type === 'function' && isRecord(rawToolChoice.function)) {
|
if (type === 'function' && isRecord(rawToolChoice.function)) {
|
||||||
const name = asTrimmedString(rawToolChoice.function.name);
|
const name = asTrimmedString(rawToolChoice.function.name);
|
||||||
return name ? { type: 'tool', name } : { type: 'any' };
|
mappedValue = name ? { type: 'tool', name } : undefined;
|
||||||
|
} else {
|
||||||
|
mappedValue = rawToolChoice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'tool' || type === 'auto' || type === 'any' || type === 'none') {
|
if (mappedValue === undefined) return undefined;
|
||||||
return rawToolChoice;
|
const toolChoiceResult = sanitizeAnthropicToolChoiceValue(mappedValue);
|
||||||
}
|
return toolChoiceResult.value;
|
||||||
|
|
||||||
return rawToolChoice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertOpenAiBodyToAnthropicMessagesBody(
|
export function convertOpenAiBodyToAnthropicMessagesBody(
|
||||||
@@ -857,7 +904,11 @@ export function validateAnthropicMessagesBody(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputConfigResult = sanitizeAnthropicOutputConfig(body.output_config ?? body.outputConfig);
|
const allowOutputConfigEffort = !!thinkingResult.value && thinkingResult.value.type === 'adaptive';
|
||||||
|
const outputConfigResult = sanitizeAnthropicOutputConfig(
|
||||||
|
body.output_config ?? body.outputConfig,
|
||||||
|
{ allowEffort: allowOutputConfigEffort },
|
||||||
|
);
|
||||||
if (outputConfigResult.error) {
|
if (outputConfigResult.error) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { anthropicMessagesInbound } from './inbound.js';
|
||||||
|
|
||||||
|
describe('anthropicMessagesInbound', () => {
|
||||||
|
it('rejects requests without a positive max_tokens value', () => {
|
||||||
|
const result = anthropicMessagesInbound.parse({
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
max_tokens: 0,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toEqual({
|
||||||
|
statusCode: 400,
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message: 'max_tokens is required and must be positive',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects system prompt blocks that are not text entries', () => {
|
||||||
|
const result = anthropicMessagesInbound.parse({
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
max_tokens: 256,
|
||||||
|
system: [
|
||||||
|
{ type: 'text', text: 'allowed' },
|
||||||
|
{ type: 'image', source: { type: 'url', url: 'https://example.com/system.png' } },
|
||||||
|
],
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toEqual({
|
||||||
|
statusCode: 400,
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message: 'system prompt must be text',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes adaptive effort and tool choice at the inbound boundary', () => {
|
||||||
|
const result = anthropicMessagesInbound.parse({
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
max_tokens: 512,
|
||||||
|
thinking: { type: 'adaptive' },
|
||||||
|
output_config: { effort: 'high', preserve: true },
|
||||||
|
tool_choice: { type: 'tool', tool: { name: 'lookup' } },
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.value?.claudeOriginalBody).toMatchObject({
|
||||||
|
thinking: { type: 'adaptive' },
|
||||||
|
output_config: { effort: 'high', preserve: true },
|
||||||
|
tool_choice: { type: 'tool', name: 'lookup' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,152 @@
|
|||||||
import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js';
|
import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js';
|
||||||
import { validateAnthropicMessagesBody } from './conversion.js';
|
import { validateAnthropicMessagesBody } from './conversion.js';
|
||||||
|
|
||||||
export const anthropicMessagesInbound = {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } {
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
const parsed = parseDownstreamChatRequest(body, 'claude');
|
}
|
||||||
if (parsed.error || !parsed.value) return parsed;
|
|
||||||
|
|
||||||
const originalBody = parsed.value.claudeOriginalBody;
|
function asTrimmedString(value: unknown): string {
|
||||||
if (originalBody) {
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
const validation = validateAnthropicMessagesBody(originalBody);
|
}
|
||||||
|
|
||||||
|
function toPositiveInteger(value: unknown): number {
|
||||||
|
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (!Number.isFinite(numberValue) || numberValue <= 0) return 0;
|
||||||
|
return Math.trunc(numberValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidRequest(message: string): { statusCode: number; payload: unknown } {
|
||||||
|
return {
|
||||||
|
statusCode: 400,
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMaxTokens(body: Record<string, unknown>): { statusCode: number; payload: unknown } | undefined {
|
||||||
|
const maxTokens = toPositiveInteger(body.max_tokens ?? body.maxTokens);
|
||||||
|
if (maxTokens > 0) return undefined;
|
||||||
|
return invalidRequest('max_tokens is required and must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSystemPrompts(body: Record<string, unknown>): { statusCode: number; payload: unknown } | undefined {
|
||||||
|
const system = body.system;
|
||||||
|
if (system === undefined || system === null || typeof system === 'string') return undefined;
|
||||||
|
if (!Array.isArray(system)) return undefined;
|
||||||
|
|
||||||
|
for (const entry of system) {
|
||||||
|
if (typeof entry === 'string') continue;
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
return invalidRequest('system prompt must be text');
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = asTrimmedString(entry.type).toLowerCase();
|
||||||
|
if (type && type !== 'text') {
|
||||||
|
return invalidRequest('system prompt must be text');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAdaptiveEffort(body: Record<string, unknown>): { statusCode: number; payload: unknown } | undefined {
|
||||||
|
const thinking = isRecord(body.thinking) ? body.thinking : null;
|
||||||
|
const thinkingType = asTrimmedString(thinking?.type).toLowerCase();
|
||||||
|
const outputConfig = isRecord(body.output_config)
|
||||||
|
? body.output_config
|
||||||
|
: (isRecord(body.outputConfig) ? body.outputConfig : null);
|
||||||
|
|
||||||
|
if (!outputConfig || !('effort' in outputConfig)) return undefined;
|
||||||
|
|
||||||
|
const effort = asTrimmedString(outputConfig.effort).toLowerCase();
|
||||||
|
if (!effort || thinkingType !== 'adaptive') return undefined;
|
||||||
|
|
||||||
|
if (!['low', 'medium', 'high', 'max'].includes(effort)) {
|
||||||
|
return invalidRequest('output_config.effort must be one of: low, medium, high, max');
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateToolChoice(body: Record<string, unknown>): { statusCode: number; payload: unknown } | undefined {
|
||||||
|
const rawToolChoice = body.tool_choice ?? body.toolChoice;
|
||||||
|
if (rawToolChoice === undefined) return undefined;
|
||||||
|
|
||||||
|
if (typeof rawToolChoice === 'string') {
|
||||||
|
const type = asTrimmedString(rawToolChoice).toLowerCase();
|
||||||
|
if (!type) return undefined;
|
||||||
|
if (type === 'required' || type === 'auto' || type === 'none' || type === 'any') return undefined;
|
||||||
|
if (type === 'tool') {
|
||||||
|
return invalidRequest('tool_choice.name is required when type is tool');
|
||||||
|
}
|
||||||
|
return invalidRequest('tool_choice.type must be one of: auto, none, any, tool');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(rawToolChoice)) {
|
||||||
|
return invalidRequest('tool_choice must be an object or string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||||
|
if (!['auto', 'none', 'any', 'tool'].includes(type)) {
|
||||||
|
return invalidRequest('tool_choice.type must be one of: auto, none, any, tool');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'tool') return undefined;
|
||||||
|
|
||||||
|
const name = asTrimmedString(
|
||||||
|
rawToolChoice.name
|
||||||
|
?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined),
|
||||||
|
);
|
||||||
|
if (!name) {
|
||||||
|
return invalidRequest('tool_choice.name is required when type is tool');
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAnthropicInboundBody(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
|
||||||
|
const maxTokensError = validateMaxTokens(body);
|
||||||
|
if (maxTokensError) return { error: maxTokensError };
|
||||||
|
|
||||||
|
const systemError = validateSystemPrompts(body);
|
||||||
|
if (systemError) return { error: systemError };
|
||||||
|
|
||||||
|
const adaptiveEffortError = validateAdaptiveEffort(body);
|
||||||
|
if (adaptiveEffortError) return { error: adaptiveEffortError };
|
||||||
|
|
||||||
|
const toolChoiceError = validateToolChoice(body);
|
||||||
|
if (toolChoiceError) return { error: toolChoiceError };
|
||||||
|
|
||||||
|
const validation = validateAnthropicMessagesBody(body);
|
||||||
if (validation.error) {
|
if (validation.error) {
|
||||||
return { error: validation.error };
|
return { error: validation.error };
|
||||||
}
|
}
|
||||||
if (validation.sanitizedBody) {
|
|
||||||
parsed.value.claudeOriginalBody = validation.sanitizedBody;
|
return {
|
||||||
|
sanitizedBody: validation.sanitizedBody ?? body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const anthropicMessagesInbound = {
|
||||||
|
parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } {
|
||||||
|
const rawBody = isRecord(body) ? body : null;
|
||||||
|
const inboundValidation = rawBody ? sanitizeAnthropicInboundBody(rawBody) : null;
|
||||||
|
if (inboundValidation?.error) {
|
||||||
|
return { error: inboundValidation.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveBody = inboundValidation?.sanitizedBody ?? body;
|
||||||
|
const parsed = parseDownstreamChatRequest(effectiveBody, 'claude');
|
||||||
|
if (parsed.error || !parsed.value) return parsed;
|
||||||
|
|
||||||
|
if (inboundValidation?.sanitizedBody) {
|
||||||
|
parsed.value.claudeOriginalBody = inboundValidation.sanitizedBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { anthropicMessagesOutbound } from './outbound.js';
|
|||||||
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
|
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
|
||||||
import { anthropicMessagesUsage } from './usage.js';
|
import { anthropicMessagesUsage } from './usage.js';
|
||||||
import { createAnthropicMessagesAggregateState } from './aggregator.js';
|
import { createAnthropicMessagesAggregateState } from './aggregator.js';
|
||||||
|
import { isMessagesRequiredError, shouldRetryNormalizedMessagesBody } from './compatibility.js';
|
||||||
export {
|
export {
|
||||||
ANTHROPIC_RAW_SSE_EVENT_NAMES,
|
ANTHROPIC_RAW_SSE_EVENT_NAMES,
|
||||||
consumeAnthropicSseEvent,
|
consumeAnthropicSseEvent,
|
||||||
@@ -20,6 +21,10 @@ export const anthropicMessagesTransformer = {
|
|||||||
outbound: anthropicMessagesOutbound,
|
outbound: anthropicMessagesOutbound,
|
||||||
stream: anthropicMessagesStream,
|
stream: anthropicMessagesStream,
|
||||||
usage: anthropicMessagesUsage,
|
usage: anthropicMessagesUsage,
|
||||||
|
compatibility: {
|
||||||
|
shouldRetryNormalizedBody: shouldRetryNormalizedMessagesBody,
|
||||||
|
isMessagesRequiredError,
|
||||||
|
},
|
||||||
aggregator: {
|
aggregator: {
|
||||||
createState: createAnthropicMessagesAggregateState,
|
createState: createAnthropicMessagesAggregateState,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { NormalizedFinalResponse } from '../../shared/normalized.js';
|
||||||
|
import { anthropicMessagesOutbound } from './outbound.js';
|
||||||
|
import { anthropicMessagesUsage } from './usage.js';
|
||||||
|
|
||||||
|
const normalized: NormalizedFinalResponse = {
|
||||||
|
id: 'chatcmpl-1',
|
||||||
|
model: 'claude-test',
|
||||||
|
created: 456,
|
||||||
|
content: 'done',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'stop',
|
||||||
|
toolCalls: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('anthropicMessagesOutbound.serializeFinal', () => {
|
||||||
|
it('round-trips native anthropic thinking/tool/text block order and signature', () => {
|
||||||
|
const payload = {
|
||||||
|
id: 'msg_native-1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'plan first',
|
||||||
|
signature: 'sig-native',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu_lookup',
|
||||||
|
name: 'lookup_weather',
|
||||||
|
input: { city: 'Paris' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'done',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stop_reason: 'tool_use',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 30,
|
||||||
|
cache_read_input_tokens: 12,
|
||||||
|
cache_creation_input_tokens: 8,
|
||||||
|
cache_creation: {
|
||||||
|
ephemeral_5m_input_tokens: 5,
|
||||||
|
ephemeral_1h_input_tokens: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = anthropicMessagesOutbound.normalizeFinal(payload, 'claude-test');
|
||||||
|
|
||||||
|
expect(anthropicMessagesOutbound.serializeFinal(
|
||||||
|
normalized,
|
||||||
|
anthropicMessagesUsage.fromPayload(payload),
|
||||||
|
)).toEqual({
|
||||||
|
id: 'msg_native-1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'plan first',
|
||||||
|
signature: 'sig-native',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu_lookup',
|
||||||
|
name: 'lookup_weather',
|
||||||
|
input: { city: 'Paris' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'done',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stop_reason: 'tool_use',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 30,
|
||||||
|
cache_read_input_tokens: 12,
|
||||||
|
cache_creation_input_tokens: 8,
|
||||||
|
cache_creation: {
|
||||||
|
ephemeral_5m_input_tokens: 5,
|
||||||
|
ephemeral_1h_input_tokens: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves anthropic cache read and cache creation semantics from parsed usage', () => {
|
||||||
|
expect(anthropicMessagesOutbound.serializeFinal(normalized, {
|
||||||
|
promptTokens: 100,
|
||||||
|
completionTokens: 30,
|
||||||
|
totalTokens: 150,
|
||||||
|
cacheReadTokens: 12,
|
||||||
|
cacheCreationTokens: 8,
|
||||||
|
promptTokensIncludeCache: false,
|
||||||
|
})).toEqual({
|
||||||
|
id: 'msg_chatcmpl-1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [{ type: 'text', text: 'done' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 30,
|
||||||
|
cache_read_input_tokens: 12,
|
||||||
|
cache_creation_input_tokens: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits cache_creation breakdown when detailed anthropic usage fields are available', () => {
|
||||||
|
expect(anthropicMessagesOutbound.serializeFinal(normalized, {
|
||||||
|
promptTokens: 100,
|
||||||
|
completionTokens: 30,
|
||||||
|
totalTokens: 150,
|
||||||
|
cacheReadInputTokens: 12,
|
||||||
|
cacheCreationInputTokens: 8,
|
||||||
|
ephemeral5mInputTokens: 5,
|
||||||
|
ephemeral1hInputTokens: 3,
|
||||||
|
promptTokensIncludingCache: 120,
|
||||||
|
})).toEqual({
|
||||||
|
id: 'msg_chatcmpl-1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [{ type: 'text', text: 'done' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 30,
|
||||||
|
cache_read_input_tokens: 12,
|
||||||
|
cache_creation_input_tokens: 8,
|
||||||
|
cache_creation: {
|
||||||
|
ephemeral_5m_input_tokens: 5,
|
||||||
|
ephemeral_1h_input_tokens: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,147 @@
|
|||||||
import { normalizeUpstreamFinalResponse, serializeFinalResponse, type NormalizedFinalResponse } from '../../shared/normalized.js';
|
import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.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 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 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) => cloneJsonValue(block));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||||
|
if (normalized.reasoningContent) {
|
||||||
|
contentBlocks.push({
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: normalized.reasoningContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 = {
|
export const anthropicMessagesOutbound = {
|
||||||
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
|
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): AnthropicMessagesNormalizedFinalResponse {
|
||||||
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
|
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) {
|
serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) {
|
||||||
return serializeFinalResponse('claude', normalized, usage as any);
|
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),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { anthropicMessagesTransformer } from './index.js';
|
||||||
|
|
||||||
|
type SerializedAnthropicEvent = {
|
||||||
|
event: string;
|
||||||
|
payload: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectSerializedEvents(events: Array<Record<string, unknown>>): SerializedAnthropicEvent[] {
|
||||||
|
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
|
||||||
|
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||||
|
const serialized = events
|
||||||
|
.flatMap((event) => anthropicMessagesTransformer.serializeStreamEvent(event as any, streamContext, downstreamContext))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return anthropicMessagesTransformer.pullSseEvents(serialized).events.map((item) => ({
|
||||||
|
event: item.event,
|
||||||
|
payload: JSON.parse(item.data),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('anthropicMessagesStream.serializeEvent', () => {
|
||||||
|
it('buffers signature deltas until the active thinking block closes', () => {
|
||||||
|
const events = collectSerializedEvents([
|
||||||
|
{
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'thinking',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reasoningDelta: 'step-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anthropic: {
|
||||||
|
signatureDelta: 'sig-buffered-until-stop',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reasoningDelta: 'step-2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anthropic: {
|
||||||
|
stopBlockIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(events.map((item) => ({
|
||||||
|
type: item.payload.type,
|
||||||
|
deltaType: item.payload.delta?.type,
|
||||||
|
thinking: item.payload.delta?.thinking,
|
||||||
|
signature: item.payload.delta?.signature,
|
||||||
|
}))).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message_start',
|
||||||
|
deltaType: undefined,
|
||||||
|
thinking: undefined,
|
||||||
|
signature: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
deltaType: undefined,
|
||||||
|
thinking: undefined,
|
||||||
|
signature: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
deltaType: 'thinking_delta',
|
||||||
|
thinking: 'step-1',
|
||||||
|
signature: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
deltaType: 'thinking_delta',
|
||||||
|
thinking: 'step-2',
|
||||||
|
signature: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
deltaType: 'signature_delta',
|
||||||
|
thinking: undefined,
|
||||||
|
signature: 'sig-buffered-until-stop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_stop',
|
||||||
|
deltaType: undefined,
|
||||||
|
thinking: undefined,
|
||||||
|
signature: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes a buffered signature when an explicit thinking block closes before redacted thinking', () => {
|
||||||
|
const events = collectSerializedEvents([
|
||||||
|
{
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'thinking',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anthropic: {
|
||||||
|
signatureDelta: 'sig-buffered',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'redacted_thinking',
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
redactedThinkingData: 'ciphertext',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(events.map((item) => item.payload.type)).toEqual([
|
||||||
|
'message_start',
|
||||||
|
'content_block_start',
|
||||||
|
'content_block_delta',
|
||||||
|
'content_block_stop',
|
||||||
|
'content_block_start',
|
||||||
|
]);
|
||||||
|
expect(events[1]?.payload.content_block).toEqual({
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: '',
|
||||||
|
});
|
||||||
|
expect(events[2]?.payload.delta).toEqual({
|
||||||
|
type: 'signature_delta',
|
||||||
|
signature: 'sig-buffered',
|
||||||
|
});
|
||||||
|
expect(events[3]?.payload.index).toBe(0);
|
||||||
|
expect(events[4]?.payload.content_block).toEqual({
|
||||||
|
type: 'redacted_thinking',
|
||||||
|
data: 'ciphertext',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the previous tool_use block before starting the next one and keeps JSON increments on the same block', () => {
|
||||||
|
const events = collectSerializedEvents([
|
||||||
|
{
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'lookup_city',
|
||||||
|
argumentsDelta: '{"city":"par',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
argumentsDelta: 'is"}',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 1,
|
||||||
|
id: 'call_2',
|
||||||
|
name: 'lookup_weather',
|
||||||
|
argumentsDelta: '{"days":2}',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(events.map((item) => ({
|
||||||
|
type: item.payload.type,
|
||||||
|
index: item.payload.index,
|
||||||
|
blockType: item.payload.content_block?.type,
|
||||||
|
deltaType: item.payload.delta?.type,
|
||||||
|
partialJson: item.payload.delta?.partial_json,
|
||||||
|
}))).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message_start',
|
||||||
|
index: undefined,
|
||||||
|
blockType: undefined,
|
||||||
|
deltaType: undefined,
|
||||||
|
partialJson: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
blockType: 'tool_use',
|
||||||
|
deltaType: undefined,
|
||||||
|
partialJson: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
blockType: undefined,
|
||||||
|
deltaType: 'input_json_delta',
|
||||||
|
partialJson: '{"city":"par',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
blockType: undefined,
|
||||||
|
deltaType: 'input_json_delta',
|
||||||
|
partialJson: 'is"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: 0,
|
||||||
|
blockType: undefined,
|
||||||
|
deltaType: undefined,
|
||||||
|
partialJson: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 1,
|
||||||
|
blockType: 'tool_use',
|
||||||
|
deltaType: undefined,
|
||||||
|
partialJson: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 1,
|
||||||
|
blockType: undefined,
|
||||||
|
deltaType: 'input_json_delta',
|
||||||
|
partialJson: '{"days":2}',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes a buffered signature on finish even when no thinking delta ever arrived', () => {
|
||||||
|
const events = collectSerializedEvents([
|
||||||
|
{
|
||||||
|
anthropic: {
|
||||||
|
signatureDelta: 'sig-finish-only',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
finishReason: 'stop',
|
||||||
|
done: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(events.map((item) => item.payload.type)).toEqual([
|
||||||
|
'message_start',
|
||||||
|
'content_block_start',
|
||||||
|
'content_block_delta',
|
||||||
|
'content_block_stop',
|
||||||
|
'message_delta',
|
||||||
|
'message_stop',
|
||||||
|
]);
|
||||||
|
expect(events[1]?.payload.content_block).toEqual({
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: '',
|
||||||
|
});
|
||||||
|
expect(events[2]?.payload.delta).toEqual({
|
||||||
|
type: 'signature_delta',
|
||||||
|
signature: 'sig-finish-only',
|
||||||
|
});
|
||||||
|
expect(events[4]?.payload.delta).toEqual({
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
stop_sequence: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps redacted_thinking open until an explicit stop arrives', () => {
|
||||||
|
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
|
||||||
|
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||||
|
|
||||||
|
const startSerialized = anthropicMessagesTransformer.serializeStreamEvent({
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'redacted_thinking',
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
redactedThinkingData: 'ciphertext',
|
||||||
|
},
|
||||||
|
} as any, streamContext, downstreamContext).join('');
|
||||||
|
const stopSerialized = anthropicMessagesTransformer.serializeStreamEvent({
|
||||||
|
anthropic: {
|
||||||
|
stopBlockIndex: 1,
|
||||||
|
},
|
||||||
|
} as any, streamContext, downstreamContext).join('');
|
||||||
|
|
||||||
|
const startEvents = anthropicMessagesTransformer.pullSseEvents(startSerialized).events.map((item) => ({
|
||||||
|
event: item.event,
|
||||||
|
payload: JSON.parse(item.data),
|
||||||
|
}));
|
||||||
|
const stopEvents = anthropicMessagesTransformer.pullSseEvents(stopSerialized).events.map((item) => ({
|
||||||
|
event: item.event,
|
||||||
|
payload: JSON.parse(item.data),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(startEvents.map((item) => item.payload.type)).toEqual([
|
||||||
|
'message_start',
|
||||||
|
'content_block_start',
|
||||||
|
]);
|
||||||
|
expect(startEvents[1]?.payload.content_block).toEqual({
|
||||||
|
type: 'redacted_thinking',
|
||||||
|
data: 'ciphertext',
|
||||||
|
});
|
||||||
|
expect(stopEvents.map((item) => item.payload.type)).toEqual([
|
||||||
|
'content_block_stop',
|
||||||
|
]);
|
||||||
|
expect(stopEvents[0]?.payload.index).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,9 +13,25 @@ import { type AnthropicExtendedStreamEvent } from './aggregator.js';
|
|||||||
|
|
||||||
type AnthropicStreamPayload = Record<string, unknown>;
|
type AnthropicStreamPayload = Record<string, unknown>;
|
||||||
|
|
||||||
|
type AnthropicBlockKind = 'thinking' | 'text' | 'tool_use' | 'redacted_thinking';
|
||||||
|
|
||||||
|
type ExtendedToolBlockState = {
|
||||||
|
contentIndex: number;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
open: boolean;
|
||||||
|
sourceIndex: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ExtendedClaudeDownstreamContext = ClaudeDownstreamContext & {
|
type ExtendedClaudeDownstreamContext = ClaudeDownstreamContext & {
|
||||||
|
toolBlocks: Record<number, ExtendedToolBlockState>;
|
||||||
thinkingBlockIndex?: number | null;
|
thinkingBlockIndex?: number | null;
|
||||||
|
thinkingSourceIndex?: number | null;
|
||||||
|
redactedBlockIndex?: number | null;
|
||||||
|
redactedSourceIndex?: number | null;
|
||||||
|
textSourceIndex?: number | null;
|
||||||
pendingSignature?: string | null;
|
pendingSignature?: string | null;
|
||||||
|
activeToolSlot?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ANTHROPIC_RAW_SSE_EVENT_NAMES = new Set([
|
export const ANTHROPIC_RAW_SSE_EVENT_NAMES = new Set([
|
||||||
@@ -56,7 +72,12 @@ export function serializeAnthropicRawSseEvent(event: string, data: string): stri
|
|||||||
function ensureContext(context: ClaudeDownstreamContext): ExtendedClaudeDownstreamContext {
|
function ensureContext(context: ClaudeDownstreamContext): ExtendedClaudeDownstreamContext {
|
||||||
const extended = context as ExtendedClaudeDownstreamContext;
|
const extended = context as ExtendedClaudeDownstreamContext;
|
||||||
if (extended.thinkingBlockIndex === undefined) extended.thinkingBlockIndex = null;
|
if (extended.thinkingBlockIndex === undefined) extended.thinkingBlockIndex = null;
|
||||||
|
if (extended.thinkingSourceIndex === undefined) extended.thinkingSourceIndex = null;
|
||||||
|
if (extended.redactedBlockIndex === undefined) extended.redactedBlockIndex = null;
|
||||||
|
if (extended.redactedSourceIndex === undefined) extended.redactedSourceIndex = null;
|
||||||
|
if (extended.textSourceIndex === undefined) extended.textSourceIndex = null;
|
||||||
if (extended.pendingSignature === undefined) extended.pendingSignature = null;
|
if (extended.pendingSignature === undefined) extended.pendingSignature = null;
|
||||||
|
if (extended.activeToolSlot === undefined) extended.activeToolSlot = null;
|
||||||
return extended;
|
return extended;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +153,7 @@ function closeTextBlock(context: ExtendedClaudeDownstreamContext): string[] {
|
|||||||
if (context.textBlockIndex === null || context.textBlockIndex === undefined) return [];
|
if (context.textBlockIndex === null || context.textBlockIndex === undefined) return [];
|
||||||
const index = context.textBlockIndex;
|
const index = context.textBlockIndex;
|
||||||
context.textBlockIndex = null;
|
context.textBlockIndex = null;
|
||||||
|
context.textSourceIndex = null;
|
||||||
context.contentBlockStarted = false;
|
context.contentBlockStarted = false;
|
||||||
return [
|
return [
|
||||||
serializeSse('content_block_stop', {
|
serializeSse('content_block_stop', {
|
||||||
@@ -159,6 +181,13 @@ function emitPendingSignature(context: ExtendedClaudeDownstreamContext): string[
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bufferPendingSignature(
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
signature: string,
|
||||||
|
): void {
|
||||||
|
context.pendingSignature = `${context.pendingSignature || ''}${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
function closeThinkingBlock(context: ExtendedClaudeDownstreamContext): string[] {
|
function closeThinkingBlock(context: ExtendedClaudeDownstreamContext): string[] {
|
||||||
if (context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) return [];
|
if (context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) return [];
|
||||||
const index = context.thinkingBlockIndex;
|
const index = context.thinkingBlockIndex;
|
||||||
@@ -170,9 +199,23 @@ function closeThinkingBlock(context: ExtendedClaudeDownstreamContext): string[]
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
context.thinkingBlockIndex = null;
|
context.thinkingBlockIndex = null;
|
||||||
|
context.thinkingSourceIndex = null;
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeRedactedBlock(context: ExtendedClaudeDownstreamContext): string[] {
|
||||||
|
if (context.redactedBlockIndex === null || context.redactedBlockIndex === undefined) return [];
|
||||||
|
const index = context.redactedBlockIndex;
|
||||||
|
context.redactedBlockIndex = null;
|
||||||
|
context.redactedSourceIndex = null;
|
||||||
|
return [
|
||||||
|
serializeSse('content_block_stop', {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function closeToolBlocks(context: ExtendedClaudeDownstreamContext): string[] {
|
function closeToolBlocks(context: ExtendedClaudeDownstreamContext): string[] {
|
||||||
const openBlocks = Object.values(context.toolBlocks)
|
const openBlocks = Object.values(context.toolBlocks)
|
||||||
.filter((item) => item.open)
|
.filter((item) => item.open)
|
||||||
@@ -188,6 +231,21 @@ function closeToolBlocks(context: ExtendedClaudeDownstreamContext): string[] {
|
|||||||
index: block.contentIndex,
|
index: block.contentIndex,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
context.activeToolSlot = null;
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitPendingSignatureAsThinkingBlock(
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
): string[] {
|
||||||
|
if (!context.pendingSignature) return [];
|
||||||
|
const signature = context.pendingSignature;
|
||||||
|
const events = [
|
||||||
|
...ensureThinkingBlockStart(context),
|
||||||
|
];
|
||||||
|
context.pendingSignature = signature;
|
||||||
|
events.push(...emitPendingSignature(context));
|
||||||
|
events.push(...closeThinkingBlock(context));
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,15 +253,41 @@ function closeAllBlocks(context: ExtendedClaudeDownstreamContext): string[] {
|
|||||||
return [
|
return [
|
||||||
...closeTextBlock(context),
|
...closeTextBlock(context),
|
||||||
...closeThinkingBlock(context),
|
...closeThinkingBlock(context),
|
||||||
|
...closeRedactedBlock(context),
|
||||||
...closeToolBlocks(context),
|
...closeToolBlocks(context),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTextBlockStart(context: ExtendedClaudeDownstreamContext): string[] {
|
function normalizeBlockIndex(value: unknown): number | null {
|
||||||
if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return [];
|
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||||
|
return Math.max(0, Math.trunc(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allocateContentIndex(
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
preferredIndex?: number | null,
|
||||||
|
): number {
|
||||||
|
const normalizedPreferred = normalizeBlockIndex(preferredIndex);
|
||||||
|
if (normalizedPreferred !== null) {
|
||||||
|
if (context.nextContentBlockIndex <= normalizedPreferred) {
|
||||||
|
context.nextContentBlockIndex = normalizedPreferred + 1;
|
||||||
|
}
|
||||||
|
return normalizedPreferred;
|
||||||
|
}
|
||||||
|
|
||||||
const index = context.nextContentBlockIndex;
|
const index = context.nextContentBlockIndex;
|
||||||
context.nextContentBlockIndex += 1;
|
context.nextContentBlockIndex += 1;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTextBlockStart(
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
preferredIndex?: number | null,
|
||||||
|
): string[] {
|
||||||
|
if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return [];
|
||||||
|
const index = allocateContentIndex(context, preferredIndex);
|
||||||
context.textBlockIndex = index;
|
context.textBlockIndex = index;
|
||||||
|
context.textSourceIndex = normalizeBlockIndex(preferredIndex) ?? index;
|
||||||
context.contentBlockStarted = true;
|
context.contentBlockStarted = true;
|
||||||
return [
|
return [
|
||||||
serializeSse('content_block_start', {
|
serializeSse('content_block_start', {
|
||||||
@@ -217,11 +301,14 @@ function ensureTextBlockStart(context: ExtendedClaudeDownstreamContext): string[
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureThinkingBlockStart(context: ExtendedClaudeDownstreamContext): string[] {
|
function ensureThinkingBlockStart(
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
preferredIndex?: number | null,
|
||||||
|
): string[] {
|
||||||
if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) return [];
|
if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) return [];
|
||||||
const index = context.nextContentBlockIndex;
|
const index = allocateContentIndex(context, preferredIndex);
|
||||||
context.nextContentBlockIndex += 1;
|
|
||||||
context.thinkingBlockIndex = index;
|
context.thinkingBlockIndex = index;
|
||||||
|
context.thinkingSourceIndex = normalizeBlockIndex(preferredIndex) ?? index;
|
||||||
return [
|
return [
|
||||||
serializeSse('content_block_start', {
|
serializeSse('content_block_start', {
|
||||||
type: 'content_block_start',
|
type: 'content_block_start',
|
||||||
@@ -234,6 +321,27 @@ function ensureThinkingBlockStart(context: ExtendedClaudeDownstreamContext): str
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureRedactedBlockStart(
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
data: string,
|
||||||
|
preferredIndex?: number | null,
|
||||||
|
): string[] {
|
||||||
|
if (context.redactedBlockIndex !== null && context.redactedBlockIndex !== undefined) return [];
|
||||||
|
const index = allocateContentIndex(context, preferredIndex);
|
||||||
|
context.redactedBlockIndex = index;
|
||||||
|
context.redactedSourceIndex = normalizeBlockIndex(preferredIndex) ?? index;
|
||||||
|
return [
|
||||||
|
serializeSse('content_block_start', {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index,
|
||||||
|
content_block: {
|
||||||
|
type: 'redacted_thinking',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function ensureToolBlockStart(
|
function ensureToolBlockStart(
|
||||||
context: ExtendedClaudeDownstreamContext,
|
context: ExtendedClaudeDownstreamContext,
|
||||||
toolDelta: NonNullable<AnthropicExtendedStreamEvent['toolCallDeltas']>[number],
|
toolDelta: NonNullable<AnthropicExtendedStreamEvent['toolCallDeltas']>[number],
|
||||||
@@ -242,12 +350,12 @@ function ensureToolBlockStart(
|
|||||||
let state = context.toolBlocks[toolSlot];
|
let state = context.toolBlocks[toolSlot];
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = {
|
state = {
|
||||||
contentIndex: context.nextContentBlockIndex,
|
contentIndex: allocateContentIndex(context),
|
||||||
id: toolDelta.id || `toolu_${toolSlot}`,
|
id: toolDelta.id || `toolu_${toolSlot}`,
|
||||||
name: toolDelta.name || `tool_${toolSlot}`,
|
name: toolDelta.name || `tool_${toolSlot}`,
|
||||||
open: false,
|
open: false,
|
||||||
|
sourceIndex: toolSlot,
|
||||||
};
|
};
|
||||||
context.nextContentBlockIndex += 1;
|
|
||||||
context.toolBlocks[toolSlot] = state;
|
context.toolBlocks[toolSlot] = state;
|
||||||
} else {
|
} else {
|
||||||
if (toolDelta.id) state.id = toolDelta.id;
|
if (toolDelta.id) state.id = toolDelta.id;
|
||||||
@@ -269,9 +377,81 @@ function ensureToolBlockStart(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.activeToolSlot = toolSlot;
|
||||||
return { events, contentIndex: state.contentIndex };
|
return { events, contentIndex: state.contentIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureExplicitToolBlockStart(
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
sourceIndex?: number | null,
|
||||||
|
): string[] {
|
||||||
|
const toolSlot = normalizeBlockIndex(sourceIndex) ?? 0;
|
||||||
|
const state = context.toolBlocks[toolSlot];
|
||||||
|
const toolDelta: NonNullable<AnthropicExtendedStreamEvent['toolCallDeltas']>[number] = {
|
||||||
|
index: toolSlot,
|
||||||
|
id: state?.id,
|
||||||
|
name: state?.name,
|
||||||
|
};
|
||||||
|
return ensureToolBlockStart(context, toolDelta).events;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreviousToolBlockIfNeeded(
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
toolSlot: number,
|
||||||
|
): string[] {
|
||||||
|
const activeToolSlot = context.activeToolSlot;
|
||||||
|
if (activeToolSlot === null || activeToolSlot === undefined || activeToolSlot === toolSlot) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return closeToolBlocks(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMatchingBlockIndex(
|
||||||
|
targetIndex: number,
|
||||||
|
contentIndex: number | null | undefined,
|
||||||
|
sourceIndex: number | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (sourceIndex !== null && sourceIndex !== undefined) return sourceIndex === targetIndex;
|
||||||
|
if (contentIndex !== null && contentIndex !== undefined) return contentIndex === targetIndex;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExplicitBlockStart(
|
||||||
|
kind: AnthropicBlockKind,
|
||||||
|
sourceIndex: number | null,
|
||||||
|
context: ExtendedClaudeDownstreamContext,
|
||||||
|
): string[] {
|
||||||
|
if (kind === 'thinking') {
|
||||||
|
return [
|
||||||
|
...closeRedactedBlock(context),
|
||||||
|
...closeToolBlocks(context),
|
||||||
|
...closeTextBlock(context),
|
||||||
|
...ensureThinkingBlockStart(context, sourceIndex),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'text') {
|
||||||
|
return [
|
||||||
|
...closeRedactedBlock(context),
|
||||||
|
...closeToolBlocks(context),
|
||||||
|
...closeThinkingBlock(context),
|
||||||
|
...ensureTextBlockStart(context, sourceIndex),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'tool_use') {
|
||||||
|
return [
|
||||||
|
...closeRedactedBlock(context),
|
||||||
|
...closeTextBlock(context),
|
||||||
|
...closeThinkingBlock(context),
|
||||||
|
...closePreviousToolBlockIfNeeded(context, normalizeBlockIndex(sourceIndex) ?? 0),
|
||||||
|
...ensureExplicitToolBlockStart(context, sourceIndex),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function buildDoneEvents(
|
function buildDoneEvents(
|
||||||
streamContext: StreamTransformContext,
|
streamContext: StreamTransformContext,
|
||||||
context: ExtendedClaudeDownstreamContext,
|
context: ExtendedClaudeDownstreamContext,
|
||||||
@@ -282,6 +462,7 @@ function buildDoneEvents(
|
|||||||
const events = [
|
const events = [
|
||||||
...ensureClaudeStartEvents(streamContext, context),
|
...ensureClaudeStartEvents(streamContext, context),
|
||||||
...closeAllBlocks(context),
|
...closeAllBlocks(context),
|
||||||
|
...emitPendingSignatureAsThinkingBlock(context),
|
||||||
serializeSse('message_delta', {
|
serializeSse('message_delta', {
|
||||||
type: 'message_delta',
|
type: 'message_delta',
|
||||||
delta: {
|
delta: {
|
||||||
@@ -384,6 +565,17 @@ function normalizeAnthropicRawEvent(
|
|||||||
const blockType = asTrimmedString(contentBlock.type);
|
const blockType = asTrimmedString(contentBlock.type);
|
||||||
const index = typeof payload.index === 'number' ? payload.index : undefined;
|
const index = typeof payload.index === 'number' ? payload.index : undefined;
|
||||||
|
|
||||||
|
if (blockType === 'text') {
|
||||||
|
return {
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'text',
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (blockType === 'thinking') {
|
if (blockType === 'thinking') {
|
||||||
return {
|
return {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
@@ -409,6 +601,12 @@ function normalizeAnthropicRawEvent(
|
|||||||
|
|
||||||
if (blockType === 'tool_use') {
|
if (blockType === 'tool_use') {
|
||||||
return {
|
return {
|
||||||
|
anthropic: {
|
||||||
|
startBlock: {
|
||||||
|
kind: 'tool_use',
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
},
|
||||||
toolCallDeltas: [{
|
toolCallDeltas: [{
|
||||||
index: typeof index === 'number' ? index : 0,
|
index: typeof index === 'number' ? index : 0,
|
||||||
id: asTrimmedString(contentBlock.id) || undefined,
|
id: asTrimmedString(contentBlock.id) || undefined,
|
||||||
@@ -548,6 +746,8 @@ export const anthropicMessagesStream = {
|
|||||||
const context = ensureContext(downstreamContext);
|
const context = ensureContext(downstreamContext);
|
||||||
const anthropicEvent = event as AnthropicExtendedStreamEvent;
|
const anthropicEvent = event as AnthropicExtendedStreamEvent;
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
const startBlock = anthropicEvent.anthropic?.startBlock;
|
||||||
|
const normalizedStartIndex = normalizeBlockIndex(startBlock?.index);
|
||||||
|
|
||||||
const needsStart = (
|
const needsStart = (
|
||||||
event.role === 'assistant'
|
event.role === 'assistant'
|
||||||
@@ -562,43 +762,30 @@ export const anthropicMessagesStream = {
|
|||||||
events.push(...ensureClaudeStartEvents(streamContext, context));
|
events.push(...ensureClaudeStartEvents(streamContext, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anthropicEvent.anthropic?.signatureDelta) {
|
if (startBlock && startBlock.kind !== 'redacted_thinking') {
|
||||||
if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) {
|
events.push(...handleExplicitBlockStart(startBlock.kind, normalizedStartIndex, context));
|
||||||
events.push(serializeSse('content_block_delta', {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
index: context.thinkingBlockIndex,
|
|
||||||
delta: {
|
|
||||||
type: 'signature_delta',
|
|
||||||
signature: anthropicEvent.anthropic.signatureDelta,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
context.pendingSignature = anthropicEvent.anthropic.signatureDelta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (anthropicEvent.anthropic?.signatureDelta) {
|
||||||
|
bufferPendingSignature(context, anthropicEvent.anthropic.signatureDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anthropicEvent.anthropic?.redactedThinkingData) {
|
if (anthropicEvent.anthropic?.redactedThinkingData) {
|
||||||
events.push(...closeAllBlocks(context));
|
events.push(...closeToolBlocks(context));
|
||||||
const index = context.nextContentBlockIndex;
|
events.push(...closeTextBlock(context));
|
||||||
context.nextContentBlockIndex += 1;
|
events.push(...closeThinkingBlock(context));
|
||||||
events.push(serializeSse('content_block_start', {
|
events.push(...ensureRedactedBlockStart(
|
||||||
type: 'content_block_start',
|
context,
|
||||||
index,
|
anthropicEvent.anthropic.redactedThinkingData,
|
||||||
content_block: {
|
normalizedStartIndex,
|
||||||
type: 'redacted_thinking',
|
));
|
||||||
data: anthropicEvent.anthropic.redactedThinkingData,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
events.push(serializeSse('content_block_stop', {
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.reasoningDelta) {
|
if (event.reasoningDelta) {
|
||||||
|
events.push(...closeRedactedBlock(context));
|
||||||
events.push(...closeToolBlocks(context));
|
events.push(...closeToolBlocks(context));
|
||||||
events.push(...closeTextBlock(context));
|
events.push(...closeTextBlock(context));
|
||||||
events.push(...ensureThinkingBlockStart(context));
|
events.push(...ensureThinkingBlockStart(context, normalizedStartIndex));
|
||||||
events.push(serializeSse('content_block_delta', {
|
events.push(serializeSse('content_block_delta', {
|
||||||
type: 'content_block_delta',
|
type: 'content_block_delta',
|
||||||
index: context.thinkingBlockIndex ?? 0,
|
index: context.thinkingBlockIndex ?? 0,
|
||||||
@@ -610,9 +797,14 @@ export const anthropicMessagesStream = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) {
|
if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) {
|
||||||
|
events.push(...closeRedactedBlock(context));
|
||||||
events.push(...closeTextBlock(context));
|
events.push(...closeTextBlock(context));
|
||||||
events.push(...closeThinkingBlock(context));
|
events.push(...closeThinkingBlock(context));
|
||||||
for (const toolDelta of event.toolCallDeltas) {
|
for (const toolDelta of event.toolCallDeltas) {
|
||||||
|
events.push(...closePreviousToolBlockIfNeeded(
|
||||||
|
context,
|
||||||
|
Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0,
|
||||||
|
));
|
||||||
const toolBlock = ensureToolBlockStart(context, toolDelta);
|
const toolBlock = ensureToolBlockStart(context, toolDelta);
|
||||||
events.push(...toolBlock.events);
|
events.push(...toolBlock.events);
|
||||||
if (toolDelta.argumentsDelta) {
|
if (toolDelta.argumentsDelta) {
|
||||||
@@ -629,9 +821,10 @@ export const anthropicMessagesStream = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.contentDelta) {
|
if (event.contentDelta) {
|
||||||
|
events.push(...closeRedactedBlock(context));
|
||||||
events.push(...closeToolBlocks(context));
|
events.push(...closeToolBlocks(context));
|
||||||
events.push(...closeThinkingBlock(context));
|
events.push(...closeThinkingBlock(context));
|
||||||
events.push(...ensureTextBlockStart(context));
|
events.push(...ensureTextBlockStart(context, normalizedStartIndex));
|
||||||
events.push(serializeSse('content_block_delta', {
|
events.push(serializeSse('content_block_delta', {
|
||||||
type: 'content_block_delta',
|
type: 'content_block_delta',
|
||||||
index: context.textBlockIndex ?? 0,
|
index: context.textBlockIndex ?? 0,
|
||||||
@@ -647,15 +840,27 @@ export const anthropicMessagesStream = {
|
|||||||
&& anthropicEvent.anthropic.stopBlockIndex !== null
|
&& anthropicEvent.anthropic.stopBlockIndex !== null
|
||||||
) {
|
) {
|
||||||
const targetIndex = anthropicEvent.anthropic.stopBlockIndex;
|
const targetIndex = anthropicEvent.anthropic.stopBlockIndex;
|
||||||
if (context.thinkingBlockIndex === targetIndex) {
|
if (isMatchingBlockIndex(targetIndex, context.thinkingBlockIndex, context.thinkingSourceIndex)) {
|
||||||
events.push(...closeThinkingBlock(context));
|
events.push(...closeThinkingBlock(context));
|
||||||
}
|
}
|
||||||
if (context.textBlockIndex === targetIndex) {
|
if (isMatchingBlockIndex(targetIndex, context.textBlockIndex, context.textSourceIndex)) {
|
||||||
events.push(...closeTextBlock(context));
|
events.push(...closeTextBlock(context));
|
||||||
}
|
}
|
||||||
const matchingToolBlock = Object.values(context.toolBlocks).find((item) => item.open && item.contentIndex === targetIndex);
|
if (isMatchingBlockIndex(targetIndex, context.redactedBlockIndex, context.redactedSourceIndex)) {
|
||||||
|
events.push(...closeRedactedBlock(context));
|
||||||
|
}
|
||||||
|
const matchingToolBlock = Object.values(context.toolBlocks).find((item) => (
|
||||||
|
item.open
|
||||||
|
&& isMatchingBlockIndex(targetIndex, item.contentIndex, item.sourceIndex)
|
||||||
|
));
|
||||||
if (matchingToolBlock) {
|
if (matchingToolBlock) {
|
||||||
matchingToolBlock.open = false;
|
matchingToolBlock.open = false;
|
||||||
|
if (typeof context.activeToolSlot === 'number') {
|
||||||
|
const activeState = context.toolBlocks[context.activeToolSlot];
|
||||||
|
if (activeState?.contentIndex === matchingToolBlock.contentIndex) {
|
||||||
|
context.activeToolSlot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
events.push(serializeSse('content_block_stop', {
|
events.push(serializeSse('content_block_stop', {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
index: matchingToolBlock.contentIndex,
|
index: matchingToolBlock.contentIndex,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
anthropicMessagesUsage,
|
||||||
|
extractAnthropicUsage,
|
||||||
|
extractAnthropicUsageMetadata,
|
||||||
|
} from './usage.js';
|
||||||
|
|
||||||
|
describe('extractAnthropicUsage', () => {
|
||||||
|
it('treats cached_tokens as already included in input_tokens for moonshot-style payloads', () => {
|
||||||
|
expect(extractAnthropicUsage({
|
||||||
|
usage: {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 30,
|
||||||
|
cached_tokens: 12,
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
promptTokens: 100,
|
||||||
|
completionTokens: 30,
|
||||||
|
totalTokens: 130,
|
||||||
|
cachedTokens: 12,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
audioInputTokens: 0,
|
||||||
|
audioOutputTokens: 0,
|
||||||
|
acceptedPredictionTokens: 0,
|
||||||
|
rejectedPredictionTokens: 0,
|
||||||
|
cacheReadInputTokens: 12,
|
||||||
|
cacheCreationInputTokens: 0,
|
||||||
|
ephemeral5mInputTokens: 0,
|
||||||
|
ephemeral1hInputTokens: 0,
|
||||||
|
promptTokensIncludingCache: 100,
|
||||||
|
serviceTier: null,
|
||||||
|
promptTokensIncludeCache: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports prompt tokens including cache without double-counting cached_tokens aliases', () => {
|
||||||
|
expect(extractAnthropicUsageMetadata({
|
||||||
|
usage: {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 30,
|
||||||
|
cached_tokens: 12,
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
cacheReadInputTokens: 12,
|
||||||
|
cacheCreationInputTokens: 0,
|
||||||
|
ephemeral5mInputTokens: 0,
|
||||||
|
ephemeral1hInputTokens: 0,
|
||||||
|
promptTokensIncludingCache: 100,
|
||||||
|
serviceTier: null,
|
||||||
|
cachedTokens: 12,
|
||||||
|
promptTokensIncludeCache: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('anthropicMessagesUsage.toPayload', () => {
|
||||||
|
it('reconstructs anthropic cache breakdown for downstream responses', () => {
|
||||||
|
const toPayload = (
|
||||||
|
anthropicMessagesUsage as typeof anthropicMessagesUsage & {
|
||||||
|
toPayload?: (usage: unknown) => unknown;
|
||||||
|
}
|
||||||
|
).toPayload;
|
||||||
|
|
||||||
|
expect(typeof toPayload).toBe('function');
|
||||||
|
expect(toPayload?.({
|
||||||
|
promptTokens: 100,
|
||||||
|
completionTokens: 30,
|
||||||
|
totalTokens: 150,
|
||||||
|
cacheReadInputTokens: 12,
|
||||||
|
cacheCreationInputTokens: 8,
|
||||||
|
ephemeral5mInputTokens: 5,
|
||||||
|
ephemeral1hInputTokens: 3,
|
||||||
|
promptTokensIncludingCache: 120,
|
||||||
|
})).toEqual({
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 30,
|
||||||
|
cache_read_input_tokens: 12,
|
||||||
|
cache_creation_input_tokens: 8,
|
||||||
|
cache_creation: {
|
||||||
|
ephemeral_5m_input_tokens: 5,
|
||||||
|
ephemeral_1h_input_tokens: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,10 @@ function isRecord(value: unknown): value is AnthropicRecord {
|
|||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOwn(record: AnthropicRecord, key: string): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(record, key);
|
||||||
|
}
|
||||||
|
|
||||||
function toPositiveInt(value: unknown): number {
|
function toPositiveInt(value: unknown): number {
|
||||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||||
if (!Number.isFinite(numberValue)) return 0;
|
if (!Number.isFinite(numberValue)) return 0;
|
||||||
@@ -18,90 +22,330 @@ function resolveAnthropicUsageRecord(payload: unknown): AnthropicRecord | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readEphemeralBreakdown(record: AnthropicRecord): {
|
||||||
|
ephemeral5mInputTokens: number;
|
||||||
|
ephemeral1hInputTokens: number;
|
||||||
|
} {
|
||||||
|
const snake = isRecord(record.cache_creation) ? record.cache_creation : null;
|
||||||
|
const camel = isRecord(record.cacheCreation) ? record.cacheCreation : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ephemeral5mInputTokens: Math.max(
|
||||||
|
toPositiveInt(snake?.ephemeral_5m_input_tokens),
|
||||||
|
toPositiveInt(camel?.ephemeral5mInputTokens),
|
||||||
|
toPositiveInt(record.ephemeral_5m_input_tokens),
|
||||||
|
toPositiveInt(record.ephemeral5mInputTokens),
|
||||||
|
),
|
||||||
|
ephemeral1hInputTokens: Math.max(
|
||||||
|
toPositiveInt(snake?.ephemeral_1h_input_tokens),
|
||||||
|
toPositiveInt(camel?.ephemeral1hInputTokens),
|
||||||
|
toPositiveInt(record.ephemeral_1h_input_tokens),
|
||||||
|
toPositiveInt(record.ephemeral1hInputTokens),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readServiceTier(record: AnthropicRecord): string | null {
|
||||||
|
if (typeof record.service_tier === 'string' && record.service_tier.trim().length > 0) {
|
||||||
|
return record.service_tier.trim();
|
||||||
|
}
|
||||||
|
if (typeof record.serviceTier === 'string' && record.serviceTier.trim().length > 0) {
|
||||||
|
return record.serviceTier.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInputTokens(record: AnthropicRecord): number {
|
||||||
|
return Math.max(
|
||||||
|
toPositiveInt(record.input_tokens),
|
||||||
|
toPositiveInt(record.inputTokens),
|
||||||
|
toPositiveInt(record.prompt_tokens),
|
||||||
|
toPositiveInt(record.promptTokens),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCompletionTokens(record: AnthropicRecord): number {
|
||||||
|
return Math.max(
|
||||||
|
toPositiveInt(record.output_tokens),
|
||||||
|
toPositiveInt(record.outputTokens),
|
||||||
|
toPositiveInt(record.completion_tokens),
|
||||||
|
toPositiveInt(record.completionTokens),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTotalTokens(record: AnthropicRecord): number {
|
||||||
|
return Math.max(
|
||||||
|
toPositiveInt(record.total_tokens),
|
||||||
|
toPositiveInt(record.totalTokens),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCacheReadTokens(record: AnthropicRecord): {
|
||||||
|
cacheReadInputTokens: number;
|
||||||
|
cachedTokens: number;
|
||||||
|
hasCachedTokensAlias: boolean;
|
||||||
|
hasExplicitCacheReadField: boolean;
|
||||||
|
} {
|
||||||
|
const cachedTokens = Math.max(
|
||||||
|
toPositiveInt(record.cached_tokens),
|
||||||
|
toPositiveInt(record.cachedTokens),
|
||||||
|
);
|
||||||
|
const cacheReadInputTokens = Math.max(
|
||||||
|
toPositiveInt(record.cache_read_input_tokens),
|
||||||
|
toPositiveInt(record.cacheReadInputTokens),
|
||||||
|
toPositiveInt(record.cacheReadTokens),
|
||||||
|
cachedTokens,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheReadInputTokens,
|
||||||
|
cachedTokens: Math.max(cachedTokens, cacheReadInputTokens),
|
||||||
|
hasCachedTokensAlias: hasOwn(record, 'cached_tokens') || hasOwn(record, 'cachedTokens'),
|
||||||
|
hasExplicitCacheReadField: (
|
||||||
|
hasOwn(record, 'cache_read_input_tokens')
|
||||||
|
|| hasOwn(record, 'cacheReadInputTokens')
|
||||||
|
|| hasOwn(record, 'cacheReadTokens')
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCacheCreationInputTokens(
|
||||||
|
record: AnthropicRecord,
|
||||||
|
breakdown: { ephemeral5mInputTokens: number; ephemeral1hInputTokens: number },
|
||||||
|
): number {
|
||||||
|
return Math.max(
|
||||||
|
toPositiveInt(record.cache_creation_input_tokens),
|
||||||
|
toPositiveInt(record.cacheCreationInputTokens),
|
||||||
|
toPositiveInt(record.cacheCreationTokens),
|
||||||
|
breakdown.ephemeral5mInputTokens + breakdown.ephemeral1hInputTokens,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPromptTokensIncludeCache(record: AnthropicRecord, cacheRead: {
|
||||||
|
hasCachedTokensAlias: boolean;
|
||||||
|
hasExplicitCacheReadField: boolean;
|
||||||
|
}): boolean | null {
|
||||||
|
if (typeof record.promptTokensIncludeCache === 'boolean') {
|
||||||
|
return record.promptTokensIncludeCache;
|
||||||
|
}
|
||||||
|
if (typeof record.prompt_tokens_include_cache === 'boolean') {
|
||||||
|
return record.prompt_tokens_include_cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheRead.hasCachedTokensAlias && !cacheRead.hasExplicitCacheReadField) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cacheRead.hasExplicitCacheReadField
|
||||||
|
|| hasOwn(record, 'cache_creation_input_tokens')
|
||||||
|
|| hasOwn(record, 'cacheCreationInputTokens')
|
||||||
|
|| hasOwn(record, 'cacheCreationTokens')
|
||||||
|
|| hasOwn(record, 'cache_creation')
|
||||||
|
|| hasOwn(record, 'cacheCreation')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export type AnthropicUsageMetadata = {
|
export type AnthropicUsageMetadata = {
|
||||||
cacheReadInputTokens: number;
|
cacheReadInputTokens: number;
|
||||||
cacheCreationInputTokens: number;
|
cacheCreationInputTokens: number;
|
||||||
ephemeral5mInputTokens: number;
|
ephemeral5mInputTokens: number;
|
||||||
ephemeral1hInputTokens: number;
|
ephemeral1hInputTokens: number;
|
||||||
promptTokensIncludingCache: number;
|
promptTokensIncludingCache: number;
|
||||||
|
promptTokensIncludeCache?: boolean | null;
|
||||||
|
serviceTier?: string | null;
|
||||||
|
cachedTokens?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
|
export type AnthropicNormalizedUsage = NormalizedUsage & AnthropicUsageMetadata & {
|
||||||
const usageRecord = resolveAnthropicUsageRecord(payload);
|
promptTokensIncludeCache?: boolean | null;
|
||||||
if (!usageRecord) {
|
};
|
||||||
return createEmptyNormalizedUsage();
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputTokens = toPositiveInt(usageRecord.input_tokens ?? usageRecord.inputTokens);
|
type AnthropicUsageSummary = {
|
||||||
const outputTokens = toPositiveInt(usageRecord.output_tokens ?? usageRecord.outputTokens);
|
inputTokens: number;
|
||||||
const cacheReadTokens = Math.max(
|
completionTokens: number;
|
||||||
toPositiveInt(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens),
|
totalTokens: number;
|
||||||
toPositiveInt(usageRecord.cached_tokens ?? usageRecord.cachedTokens),
|
cacheReadInputTokens: number;
|
||||||
);
|
cacheCreationInputTokens: number;
|
||||||
const cacheCreationTokens = Math.max(
|
ephemeral5mInputTokens: number;
|
||||||
toPositiveInt(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens),
|
ephemeral1hInputTokens: number;
|
||||||
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_5m_input_tokens)
|
promptTokensIncludingCache: number;
|
||||||
+ toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_1h_input_tokens),
|
promptTokensIncludeCache: boolean | null;
|
||||||
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral5mInputTokens)
|
serviceTier: string | null;
|
||||||
+ toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral1hInputTokens),
|
cachedTokens: number;
|
||||||
);
|
};
|
||||||
|
|
||||||
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
|
||||||
const completionTokens = outputTokens;
|
|
||||||
const totalTokens = Math.max(
|
|
||||||
toPositiveInt(usageRecord.total_tokens ?? usageRecord.totalTokens),
|
|
||||||
promptTokens + completionTokens,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
function summarizeAnthropicUsage(payload: unknown): AnthropicUsageSummary {
|
||||||
|
const record = resolveAnthropicUsageRecord(payload);
|
||||||
|
if (!record) {
|
||||||
return {
|
return {
|
||||||
promptTokens,
|
inputTokens: 0,
|
||||||
completionTokens,
|
completionTokens: 0,
|
||||||
totalTokens,
|
totalTokens: 0,
|
||||||
cachedTokens: cacheReadTokens,
|
|
||||||
reasoningTokens: 0,
|
|
||||||
audioInputTokens: 0,
|
|
||||||
audioOutputTokens: 0,
|
|
||||||
acceptedPredictionTokens: 0,
|
|
||||||
rejectedPredictionTokens: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractAnthropicUsageMetadata(payload: unknown): AnthropicUsageMetadata {
|
|
||||||
const usageRecord = resolveAnthropicUsageRecord(payload);
|
|
||||||
if (!usageRecord) {
|
|
||||||
return {
|
|
||||||
cacheReadInputTokens: 0,
|
cacheReadInputTokens: 0,
|
||||||
cacheCreationInputTokens: 0,
|
cacheCreationInputTokens: 0,
|
||||||
ephemeral5mInputTokens: 0,
|
ephemeral5mInputTokens: 0,
|
||||||
ephemeral1hInputTokens: 0,
|
ephemeral1hInputTokens: 0,
|
||||||
promptTokensIncludingCache: 0,
|
promptTokensIncludingCache: 0,
|
||||||
|
promptTokensIncludeCache: null,
|
||||||
|
serviceTier: null,
|
||||||
|
cachedTokens: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputTokens = toPositiveInt(usageRecord.input_tokens ?? usageRecord.inputTokens);
|
const breakdown = readEphemeralBreakdown(record);
|
||||||
const cacheReadInputTokens = Math.max(
|
const cacheRead = readCacheReadTokens(record);
|
||||||
toPositiveInt(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens),
|
const cacheCreationInputTokens = readCacheCreationInputTokens(record, breakdown);
|
||||||
toPositiveInt(usageRecord.cached_tokens ?? usageRecord.cachedTokens),
|
const promptTokensIncludeCache = readPromptTokensIncludeCache(record, cacheRead);
|
||||||
);
|
|
||||||
const ephemeral5mInputTokens = Math.max(
|
let inputTokens = readInputTokens(record);
|
||||||
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_5m_input_tokens),
|
const completionTokens = readCompletionTokens(record);
|
||||||
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral5mInputTokens),
|
|
||||||
);
|
const explicitPromptTokensIncludingCache = Math.max(
|
||||||
const ephemeral1hInputTokens = Math.max(
|
toPositiveInt(record.promptTokensIncludingCache),
|
||||||
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_1h_input_tokens),
|
toPositiveInt(record.prompt_tokens_including_cache),
|
||||||
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral1hInputTokens),
|
|
||||||
);
|
|
||||||
const cacheCreationInputTokens = Math.max(
|
|
||||||
toPositiveInt(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens),
|
|
||||||
ephemeral5mInputTokens + ephemeral1hInputTokens,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let promptTokensIncludingCache = explicitPromptTokensIncludingCache;
|
||||||
|
if (promptTokensIncludingCache <= 0) {
|
||||||
|
if (promptTokensIncludeCache === true) {
|
||||||
|
promptTokensIncludingCache = inputTokens;
|
||||||
|
} else if (promptTokensIncludeCache === false) {
|
||||||
|
promptTokensIncludingCache = inputTokens + cacheRead.cacheReadInputTokens + cacheCreationInputTokens;
|
||||||
|
} else {
|
||||||
|
promptTokensIncludingCache = Math.max(
|
||||||
|
inputTokens,
|
||||||
|
inputTokens + cacheRead.cacheReadInputTokens + cacheCreationInputTokens,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputTokens <= 0 && promptTokensIncludingCache > 0) {
|
||||||
|
inputTokens = Math.max(
|
||||||
|
promptTokensIncludingCache - cacheRead.cacheReadInputTokens - cacheCreationInputTokens,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cacheReadInputTokens,
|
inputTokens,
|
||||||
|
completionTokens,
|
||||||
|
totalTokens: Math.max(
|
||||||
|
readTotalTokens(record),
|
||||||
|
promptTokensIncludingCache + completionTokens,
|
||||||
|
),
|
||||||
|
cacheReadInputTokens: cacheRead.cacheReadInputTokens,
|
||||||
cacheCreationInputTokens,
|
cacheCreationInputTokens,
|
||||||
ephemeral5mInputTokens,
|
ephemeral5mInputTokens: breakdown.ephemeral5mInputTokens,
|
||||||
ephemeral1hInputTokens,
|
ephemeral1hInputTokens: breakdown.ephemeral1hInputTokens,
|
||||||
promptTokensIncludingCache: inputTokens + cacheReadInputTokens + cacheCreationInputTokens,
|
promptTokensIncludingCache,
|
||||||
|
promptTokensIncludeCache,
|
||||||
|
serviceTier: readServiceTier(record),
|
||||||
|
cachedTokens: cacheRead.cachedTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
|
||||||
|
const summary = summarizeAnthropicUsage(payload);
|
||||||
|
return {
|
||||||
|
promptTokens: summary.promptTokensIncludingCache,
|
||||||
|
completionTokens: summary.completionTokens,
|
||||||
|
totalTokens: summary.totalTokens,
|
||||||
|
cachedTokens: summary.cacheReadInputTokens,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
audioInputTokens: 0,
|
||||||
|
audioOutputTokens: 0,
|
||||||
|
acceptedPredictionTokens: 0,
|
||||||
|
rejectedPredictionTokens: 0,
|
||||||
|
cacheReadInputTokens: summary.cacheReadInputTokens,
|
||||||
|
cacheCreationInputTokens: summary.cacheCreationInputTokens,
|
||||||
|
ephemeral5mInputTokens: summary.ephemeral5mInputTokens,
|
||||||
|
ephemeral1hInputTokens: summary.ephemeral1hInputTokens,
|
||||||
|
promptTokensIncludingCache: summary.promptTokensIncludingCache,
|
||||||
|
promptTokensIncludeCache: summary.promptTokensIncludeCache,
|
||||||
|
serviceTier: summary.serviceTier,
|
||||||
|
} as AnthropicNormalizedUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAnthropicUsageMetadata(payload: unknown): AnthropicUsageMetadata {
|
||||||
|
const summary = summarizeAnthropicUsage(payload);
|
||||||
|
return {
|
||||||
|
cacheReadInputTokens: summary.cacheReadInputTokens,
|
||||||
|
cacheCreationInputTokens: summary.cacheCreationInputTokens,
|
||||||
|
ephemeral5mInputTokens: summary.ephemeral5mInputTokens,
|
||||||
|
ephemeral1hInputTokens: summary.ephemeral1hInputTokens,
|
||||||
|
promptTokensIncludingCache: summary.promptTokensIncludingCache,
|
||||||
|
promptTokensIncludeCache: summary.promptTokensIncludeCache,
|
||||||
|
serviceTier: summary.serviceTier,
|
||||||
|
cachedTokens: summary.cachedTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAnthropicUsagePayload(usage: unknown): Record<string, unknown> {
|
||||||
|
const summary = summarizeAnthropicUsage(usage);
|
||||||
|
const shouldDeriveInputTokens = (
|
||||||
|
summary.cacheReadInputTokens > 0
|
||||||
|
|| summary.cacheCreationInputTokens > 0
|
||||||
|
|| summary.promptTokensIncludeCache === true
|
||||||
|
|| summary.promptTokensIncludingCache !== summary.inputTokens
|
||||||
|
);
|
||||||
|
const inputTokens = shouldDeriveInputTokens
|
||||||
|
? Math.max(
|
||||||
|
summary.promptTokensIncludingCache - summary.cacheReadInputTokens - summary.cacheCreationInputTokens,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: summary.inputTokens;
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: summary.completionTokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (summary.cacheReadInputTokens > 0) {
|
||||||
|
payload.cache_read_input_tokens = summary.cacheReadInputTokens;
|
||||||
|
}
|
||||||
|
if (summary.cacheCreationInputTokens > 0) {
|
||||||
|
payload.cache_creation_input_tokens = summary.cacheCreationInputTokens;
|
||||||
|
}
|
||||||
|
if (summary.ephemeral5mInputTokens > 0 || summary.ephemeral1hInputTokens > 0) {
|
||||||
|
payload.cache_creation = {
|
||||||
|
ephemeral_5m_input_tokens: summary.ephemeral5mInputTokens,
|
||||||
|
ephemeral_1h_input_tokens: summary.ephemeral1hInputTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (summary.serviceTier) {
|
||||||
|
payload.service_tier = summary.serviceTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAnthropicUsage(
|
||||||
|
base: NormalizedUsage | undefined,
|
||||||
|
next: NormalizedUsage | undefined,
|
||||||
|
): AnthropicNormalizedUsage {
|
||||||
|
const mergedBase = mergeNormalizedUsage(base, next) as AnthropicNormalizedUsage;
|
||||||
|
const baseUsage = (base ?? {}) as AnthropicNormalizedUsage;
|
||||||
|
const nextUsage = (next ?? {}) as AnthropicNormalizedUsage;
|
||||||
|
|
||||||
|
const basePromptTokensIncludingCache = toPositiveInt(baseUsage.promptTokensIncludingCache ?? baseUsage.promptTokens);
|
||||||
|
const nextPromptTokensIncludingCache = toPositiveInt(nextUsage.promptTokensIncludingCache ?? nextUsage.promptTokens);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mergedBase,
|
||||||
|
cacheReadInputTokens: toPositiveInt(baseUsage.cacheReadInputTokens) + toPositiveInt(nextUsage.cacheReadInputTokens),
|
||||||
|
cacheCreationInputTokens: toPositiveInt(baseUsage.cacheCreationInputTokens) + toPositiveInt(nextUsage.cacheCreationInputTokens),
|
||||||
|
ephemeral5mInputTokens: toPositiveInt(baseUsage.ephemeral5mInputTokens) + toPositiveInt(nextUsage.ephemeral5mInputTokens),
|
||||||
|
ephemeral1hInputTokens: toPositiveInt(baseUsage.ephemeral1hInputTokens) + toPositiveInt(nextUsage.ephemeral1hInputTokens),
|
||||||
|
promptTokensIncludingCache: basePromptTokensIncludingCache + nextPromptTokensIncludingCache,
|
||||||
|
promptTokensIncludeCache: nextUsage.promptTokensIncludeCache ?? baseUsage.promptTokensIncludeCache ?? null,
|
||||||
|
serviceTier: typeof nextUsage.serviceTier === 'string'
|
||||||
|
? nextUsage.serviceTier
|
||||||
|
: (typeof baseUsage.serviceTier === 'string' ? baseUsage.serviceTier : null),
|
||||||
|
cachedTokens: toPositiveInt(baseUsage.cachedTokens) + toPositiveInt(nextUsage.cachedTokens),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,5 +353,6 @@ export const anthropicMessagesUsage = {
|
|||||||
empty: createEmptyNormalizedUsage,
|
empty: createEmptyNormalizedUsage,
|
||||||
fromPayload: extractAnthropicUsage,
|
fromPayload: extractAnthropicUsage,
|
||||||
metadataFromPayload: extractAnthropicUsageMetadata,
|
metadataFromPayload: extractAnthropicUsageMetadata,
|
||||||
merge: mergeNormalizedUsage,
|
toPayload: toAnthropicUsagePayload,
|
||||||
|
merge: mergeAnthropicUsage,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyGeminiGenerateContentAggregate,
|
||||||
|
createGeminiGenerateContentAggregateState,
|
||||||
|
} from './aggregator.js';
|
||||||
|
import {
|
||||||
|
extractResponseMetadata,
|
||||||
|
serializeGeminiAggregateResponse,
|
||||||
|
} from './outbound.js';
|
||||||
|
import { extractGeminiUsage } from './usage.js';
|
||||||
|
|
||||||
|
describe('Gemini aggregate/outbound/usage', () => {
|
||||||
|
it('coalesces chunked text and thought parts per candidate while preserving tool thought signatures', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
|
||||||
|
applyGeminiGenerateContentAggregate(state, [
|
||||||
|
{
|
||||||
|
responseId: 'resp-coalesce',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'Think ', thought: true, thoughtSignature: 'sig-think-1' },
|
||||||
|
{ text: 'Hello ' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'Alt ', thought: true, thoughtSignature: 'sig-think-2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseId: 'resp-coalesce',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
groundingMetadata: { webSearchQueries: ['cats'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/cats' }] },
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'deeply', thought: true, thoughtSignature: 'sig-think-1' },
|
||||||
|
{ text: 'world' },
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
id: 'tool-call-1',
|
||||||
|
name: 'lookupDocs',
|
||||||
|
args: { query: 'cats' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'sig-tool-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: 'MAX_TOKENS',
|
||||||
|
groundingMetadata: { webSearchQueries: ['dogs'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/dogs' }] },
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'answer', thought: true, thoughtSignature: 'sig-think-2' },
|
||||||
|
{ text: 'candidate' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||||
|
responseId: 'resp-coalesce',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{ text: 'Think deeply', thought: true, thoughtSignature: 'sig-think-1' },
|
||||||
|
{ text: 'Hello world' },
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
id: 'tool-call-1',
|
||||||
|
name: 'lookupDocs',
|
||||||
|
args: { query: 'cats' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'sig-tool-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['cats'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/cats' }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: 'MAX_TOKENS',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{ text: 'Alt answer', thought: true, thoughtSignature: 'sig-think-2' },
|
||||||
|
{ text: 'candidate' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['dogs'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/dogs' }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflows preserved request metadata alongside aggregated grounding, citation, thought, and usage data', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
applyGeminiGenerateContentAggregate(state, {
|
||||||
|
responseId: 'resp-meta',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
groundingMetadata: { webSearchQueries: ['cats'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/cats' }] },
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'reasoning', thought: true, thoughtSignature: 'sig-think-1' },
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'lookupWeather',
|
||||||
|
args: { city: 'Paris' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'sig-tool-1',
|
||||||
|
},
|
||||||
|
{ text: 'final answer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 15,
|
||||||
|
candidatesTokenCount: 5,
|
||||||
|
totalTokenCount: 24,
|
||||||
|
cachedContentTokenCount: 2,
|
||||||
|
thoughtsTokenCount: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = extractResponseMetadata(state, {
|
||||||
|
systemInstruction: { role: 'system', parts: [{ text: 'stay concise' }] },
|
||||||
|
cachedContent: 'cached/item-1',
|
||||||
|
safetySettings: [{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_LOW_AND_ABOVE' }],
|
||||||
|
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
|
||||||
|
generationConfig: {
|
||||||
|
stopSequences: ['END'],
|
||||||
|
responseModalities: ['TEXT', 'AUDIO'],
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: { type: 'object', properties: { answer: { type: 'string' } } },
|
||||||
|
candidateCount: 2,
|
||||||
|
temperature: 0.3,
|
||||||
|
topP: 0.8,
|
||||||
|
topK: 20,
|
||||||
|
presencePenalty: 0.1,
|
||||||
|
frequencyPenalty: 0.2,
|
||||||
|
seed: 42,
|
||||||
|
responseLogprobs: true,
|
||||||
|
logprobs: 5,
|
||||||
|
thinkingConfig: { thinkingLevel: 'high', includeThoughts: true },
|
||||||
|
imageConfig: { aspectRatio: '16:9' },
|
||||||
|
},
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
functionDeclarations: [
|
||||||
|
{
|
||||||
|
name: 'lookupWeather',
|
||||||
|
description: 'Lookup weather',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ googleSearch: {} },
|
||||||
|
{ urlContext: {} },
|
||||||
|
{ codeExecution: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metadata).toEqual({
|
||||||
|
systemInstruction: { role: 'system', parts: [{ text: 'stay concise' }] },
|
||||||
|
cachedContent: 'cached/item-1',
|
||||||
|
safetySettings: [{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_LOW_AND_ABOVE' }],
|
||||||
|
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
|
||||||
|
stopSequences: ['END'],
|
||||||
|
responseModalities: ['TEXT', 'AUDIO'],
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: { type: 'object', properties: { answer: { type: 'string' } } },
|
||||||
|
candidateCount: 2,
|
||||||
|
temperature: 0.3,
|
||||||
|
topP: 0.8,
|
||||||
|
topK: 20,
|
||||||
|
presencePenalty: 0.1,
|
||||||
|
frequencyPenalty: 0.2,
|
||||||
|
seed: 42,
|
||||||
|
responseLogprobs: true,
|
||||||
|
logprobs: 5,
|
||||||
|
thinkingConfig: { thinkingLevel: 'high', includeThoughts: true },
|
||||||
|
imageConfig: { aspectRatio: '16:9' },
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
functionDeclarations: [
|
||||||
|
{
|
||||||
|
name: 'lookupWeather',
|
||||||
|
description: 'Lookup weather',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ googleSearch: {} },
|
||||||
|
{ urlContext: {} },
|
||||||
|
{ codeExecution: {} },
|
||||||
|
],
|
||||||
|
citations: [{ citations: [{ uri: 'https://example.com/cats' }] }],
|
||||||
|
groundingMetadata: [{ webSearchQueries: ['cats'] }],
|
||||||
|
thoughtSignature: 'sig-think-1',
|
||||||
|
thoughtSignatures: ['sig-think-1', 'sig-tool-1'],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 15,
|
||||||
|
candidatesTokenCount: 5,
|
||||||
|
totalTokenCount: 24,
|
||||||
|
cachedContentTokenCount: 2,
|
||||||
|
thoughtsTokenCount: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges split candidate metadata and emits top-level metadata in candidate order', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
applyGeminiGenerateContentAggregate(state, [
|
||||||
|
{
|
||||||
|
responseId: 'resp-metadata-merge',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
groundingMetadata: {
|
||||||
|
webSearchQueries: ['dogs'],
|
||||||
|
},
|
||||||
|
citationMetadata: {
|
||||||
|
citations: [{ uri: 'https://example.com/dogs-1' }],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'candidate-1' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
groundingMetadata: {
|
||||||
|
webSearchQueries: ['cats'],
|
||||||
|
},
|
||||||
|
citationMetadata: {
|
||||||
|
citations: [{ uri: 'https://example.com/cats-1' }],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'candidate-0' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseId: 'resp-metadata-merge',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
groundingMetadata: {
|
||||||
|
groundingChunks: [{ id: 'dogs-chunk' }],
|
||||||
|
},
|
||||||
|
citationMetadata: {
|
||||||
|
citations: [{ uri: 'https://example.com/dogs-2' }],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
parts: [{ text: '-done' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
groundingMetadata: {
|
||||||
|
groundingChunks: [{ id: 'cats-chunk' }],
|
||||||
|
},
|
||||||
|
citationMetadata: {
|
||||||
|
citations: [{ uri: 'https://example.com/cats-2' }],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
parts: [{ text: '-done' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = serializeGeminiAggregateResponse(state);
|
||||||
|
const metadata = extractResponseMetadata(state);
|
||||||
|
|
||||||
|
expect(response.candidates).toEqual([
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'candidate-0-done' }],
|
||||||
|
},
|
||||||
|
groundingMetadata: {
|
||||||
|
webSearchQueries: ['cats'],
|
||||||
|
groundingChunks: [{ id: 'cats-chunk' }],
|
||||||
|
},
|
||||||
|
citationMetadata: {
|
||||||
|
citations: [
|
||||||
|
{ uri: 'https://example.com/cats-1' },
|
||||||
|
{ uri: 'https://example.com/cats-2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'candidate-1-done' }],
|
||||||
|
},
|
||||||
|
groundingMetadata: {
|
||||||
|
webSearchQueries: ['dogs'],
|
||||||
|
groundingChunks: [{ id: 'dogs-chunk' }],
|
||||||
|
},
|
||||||
|
citationMetadata: {
|
||||||
|
citations: [
|
||||||
|
{ uri: 'https://example.com/dogs-1' },
|
||||||
|
{ uri: 'https://example.com/dogs-2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(metadata.groundingMetadata).toEqual([
|
||||||
|
{
|
||||||
|
webSearchQueries: ['cats'],
|
||||||
|
groundingChunks: [{ id: 'cats-chunk' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
webSearchQueries: ['dogs'],
|
||||||
|
groundingChunks: [{ id: 'dogs-chunk' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(metadata.citations).toEqual([
|
||||||
|
{
|
||||||
|
citations: [
|
||||||
|
{ uri: 'https://example.com/cats-1' },
|
||||||
|
{ uri: 'https://example.com/cats-2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
citations: [
|
||||||
|
{ uri: 'https://example.com/dogs-1' },
|
||||||
|
{ uri: 'https://example.com/dogs-2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves multi-candidate tool thought signatures in serialized Gemini responses', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
applyGeminiGenerateContentAggregate(state, [
|
||||||
|
{
|
||||||
|
responseId: 'resp-multi-tools',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
id: 'call_2',
|
||||||
|
name: 'lookupDocs',
|
||||||
|
args: { query: 'transformers' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'sig-candidate-2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['transformers'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseId: 'resp-multi-tools',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'candidate zero answer', thoughtSignature: 'sig-candidate-1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/zero' }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||||
|
responseId: 'resp-multi-tools',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'candidate zero answer', thoughtSignature: 'sig-candidate-1' }],
|
||||||
|
},
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/zero' }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
id: 'call_2',
|
||||||
|
name: 'lookupDocs',
|
||||||
|
args: { query: 'transformers' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'sig-candidate-2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['transformers'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers final answer thought signature for scalar metadata while retaining tool signatures in the array', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
applyGeminiGenerateContentAggregate(state, [
|
||||||
|
{
|
||||||
|
responseId: 'resp-thought-order',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'lookupWeather',
|
||||||
|
args: { city: 'Paris' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'sig-tool-first',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseId: 'resp-thought-order',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'reasoning', thought: true, thoughtSignature: 'sig-answer-final' },
|
||||||
|
{ text: 'answer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const metadata = extractResponseMetadata(state);
|
||||||
|
|
||||||
|
expect(metadata.thoughtSignature).toBe('sig-answer-final');
|
||||||
|
expect(metadata.thoughtSignatures).toEqual([
|
||||||
|
'sig-tool-first',
|
||||||
|
'sig-answer-final',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Gemini usage from aggregate state and serialized response consistently', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
applyGeminiGenerateContentAggregate(state, {
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 20,
|
||||||
|
candidatesTokenCount: 7,
|
||||||
|
totalTokenCount: 31,
|
||||||
|
cachedContentTokenCount: 4,
|
||||||
|
thoughtsTokenCount: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialized = serializeGeminiAggregateResponse(state);
|
||||||
|
|
||||||
|
expect(extractGeminiUsage(state)).toEqual({
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 11,
|
||||||
|
totalTokens: 31,
|
||||||
|
cachedTokens: 4,
|
||||||
|
reasoningTokens: 4,
|
||||||
|
});
|
||||||
|
expect(extractGeminiUsage(serialized)).toEqual({
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 11,
|
||||||
|
totalTokens: 31,
|
||||||
|
cachedTokens: 4,
|
||||||
|
reasoningTokens: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops non-finite Gemini usage values instead of leaking NaN or Infinity into normalized usage', () => {
|
||||||
|
const usage = extractGeminiUsage({
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: Number.NaN,
|
||||||
|
candidatesTokenCount: 6,
|
||||||
|
totalTokenCount: Number.POSITIVE_INFINITY,
|
||||||
|
cachedContentTokenCount: Number.NaN,
|
||||||
|
thoughtsTokenCount: Number.NEGATIVE_INFINITY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usage).toEqual({
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 6,
|
||||||
|
totalTokens: 6,
|
||||||
|
cachedTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps request-preserved metadata stable after serialize -> metadata extraction round-trip', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
applyGeminiGenerateContentAggregate(state, {
|
||||||
|
responseId: 'resp-roundtrip',
|
||||||
|
modelVersion: 'gemini-3.1-pro-preview',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
groundingMetadata: { webSearchQueries: ['roundtrip'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/roundtrip' }] },
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: 'thinking', thought: true, thoughtSignature: 'sig-roundtrip' },
|
||||||
|
{ text: 'final answer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 8,
|
||||||
|
candidatesTokenCount: 3,
|
||||||
|
totalTokenCount: 13,
|
||||||
|
cachedContentTokenCount: 1,
|
||||||
|
thoughtsTokenCount: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestPayload = {
|
||||||
|
systemInstruction: { role: 'system', parts: [{ text: 'preserve me' }] },
|
||||||
|
cachedContent: 'cached/roundtrip',
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['TEXT', 'AUDIO'],
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: { type: 'object' },
|
||||||
|
thinkingConfig: { thinkingLevel: 'high', includeThoughts: true },
|
||||||
|
},
|
||||||
|
tools: [{ googleSearch: {} }, { codeExecution: {} }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromState = extractResponseMetadata(state, requestPayload);
|
||||||
|
const serialized = serializeGeminiAggregateResponse(state);
|
||||||
|
const fromSerialized = extractResponseMetadata(serialized, requestPayload);
|
||||||
|
|
||||||
|
expect(fromSerialized).toEqual(fromState);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,13 +16,78 @@ function cloneJsonValue<T>(value: T): T {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stableSerialize(value: unknown): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeJsonArrays(existing: unknown[], incoming: unknown[]): unknown[] {
|
||||||
|
const merged = existing.map((item) => cloneJsonValue(item));
|
||||||
|
const seen = new Set(merged.map((item) => stableSerialize(item)));
|
||||||
|
for (const item of incoming) {
|
||||||
|
const cloned = cloneJsonValue(item);
|
||||||
|
const serialized = stableSerialize(cloned);
|
||||||
|
if (seen.has(serialized)) continue;
|
||||||
|
seen.add(serialized);
|
||||||
|
merged.push(cloned);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeJsonValues(existing: unknown, incoming: unknown): unknown {
|
||||||
|
if (incoming === undefined) return cloneJsonValue(existing);
|
||||||
|
if (existing === undefined) return cloneJsonValue(incoming);
|
||||||
|
if (Array.isArray(existing) && Array.isArray(incoming)) {
|
||||||
|
return mergeJsonArrays(existing, incoming);
|
||||||
|
}
|
||||||
|
if (isRecord(existing) && isRecord(incoming)) {
|
||||||
|
return mergeJsonRecords(existing, incoming);
|
||||||
|
}
|
||||||
|
return cloneJsonValue(incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeJsonRecords(existing: GeminiRecord, incoming: GeminiRecord): GeminiRecord {
|
||||||
|
const merged: GeminiRecord = Object.fromEntries(
|
||||||
|
Object.entries(existing).map(([key, value]) => [key, cloneJsonValue(value)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(incoming)) {
|
||||||
|
merged[key] = mergeJsonValues(merged[key], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
function pushUniqueJson(target: GeminiRecord[], incoming: unknown): void {
|
function pushUniqueJson(target: GeminiRecord[], incoming: unknown): void {
|
||||||
if (!isRecord(incoming)) return;
|
if (!isRecord(incoming)) return;
|
||||||
const serialized = JSON.stringify(incoming);
|
const serialized = stableSerialize(incoming);
|
||||||
if (target.some((item) => JSON.stringify(item) === serialized)) return;
|
if (target.some((item) => stableSerialize(item) === serialized)) return;
|
||||||
target.push(cloneJsonValue(incoming));
|
target.push(cloneJsonValue(incoming));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTextPart(value: unknown): value is GeminiRecord & { text: string } {
|
||||||
|
return isRecord(value) && typeof value.text === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function partComparableShape(value: GeminiRecord & { text: string }): string {
|
||||||
|
const { text: _text, ...rest } = value;
|
||||||
|
return stableSerialize(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPart(target: GeminiRecord[], incoming: unknown): void {
|
||||||
|
if (!isRecord(incoming)) return;
|
||||||
|
const next = cloneJsonValue(incoming);
|
||||||
|
if (isTextPart(next)) {
|
||||||
|
for (let index = target.length - 1; index >= 0; index -= 1) {
|
||||||
|
const existing = target[index];
|
||||||
|
if (!isTextPart(existing)) continue;
|
||||||
|
if (partComparableShape(existing) !== partComparableShape(next)) continue;
|
||||||
|
existing.text = `${existing.text}${next.text}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.push(next);
|
||||||
|
}
|
||||||
|
|
||||||
function collectPartsFromPayload(payload: unknown): GeminiRecord[] {
|
function collectPartsFromPayload(payload: unknown): GeminiRecord[] {
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
return payload.flatMap((item) => collectPartsFromPayload(item));
|
return payload.flatMap((item) => collectPartsFromPayload(item));
|
||||||
@@ -119,7 +184,7 @@ export function applyGeminiGenerateContentAggregate(
|
|||||||
payload: unknown,
|
payload: unknown,
|
||||||
): GeminiGenerateContentAggregateState {
|
): GeminiGenerateContentAggregateState {
|
||||||
for (const part of collectPartsFromPayload(payload)) {
|
for (const part of collectPartsFromPayload(payload)) {
|
||||||
state.parts.push(part);
|
appendPart(state.parts, part);
|
||||||
if (typeof part.thoughtSignature === 'string' && part.thoughtSignature.trim()) {
|
if (typeof part.thoughtSignature === 'string' && part.thoughtSignature.trim()) {
|
||||||
if (!state.thoughtSignatures.includes(part.thoughtSignature)) {
|
if (!state.thoughtSignatures.includes(part.thoughtSignature)) {
|
||||||
state.thoughtSignatures.push(part.thoughtSignature);
|
state.thoughtSignatures.push(part.thoughtSignature);
|
||||||
@@ -145,13 +210,17 @@ export function applyGeminiGenerateContentAggregate(
|
|||||||
if (candidate.groundingMetadata !== undefined) {
|
if (candidate.groundingMetadata !== undefined) {
|
||||||
pushUniqueJson(state.groundingMetadata, candidate.groundingMetadata);
|
pushUniqueJson(state.groundingMetadata, candidate.groundingMetadata);
|
||||||
if (isRecord(candidate.groundingMetadata)) {
|
if (isRecord(candidate.groundingMetadata)) {
|
||||||
candidateAggregate.groundingMetadata = cloneJsonValue(candidate.groundingMetadata);
|
candidateAggregate.groundingMetadata = candidateAggregate.groundingMetadata
|
||||||
|
? mergeJsonRecords(candidateAggregate.groundingMetadata, candidate.groundingMetadata)
|
||||||
|
: cloneJsonValue(candidate.groundingMetadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (candidate.citationMetadata !== undefined) {
|
if (candidate.citationMetadata !== undefined) {
|
||||||
pushUniqueJson(state.citations, candidate.citationMetadata);
|
pushUniqueJson(state.citations, candidate.citationMetadata);
|
||||||
if (isRecord(candidate.citationMetadata)) {
|
if (isRecord(candidate.citationMetadata)) {
|
||||||
candidateAggregate.citationMetadata = cloneJsonValue(candidate.citationMetadata);
|
candidateAggregate.citationMetadata = candidateAggregate.citationMetadata
|
||||||
|
? mergeJsonRecords(candidateAggregate.citationMetadata, candidate.citationMetadata)
|
||||||
|
: cloneJsonValue(candidate.citationMetadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof candidate.finishReason === 'string' && candidate.finishReason.trim()) {
|
if (typeof candidate.finishReason === 'string' && candidate.finishReason.trim()) {
|
||||||
@@ -163,7 +232,7 @@ export function applyGeminiGenerateContentAggregate(
|
|||||||
if (content && Array.isArray(content.parts)) {
|
if (content && Array.isArray(content.parts)) {
|
||||||
for (const part of content.parts) {
|
for (const part of content.parts) {
|
||||||
if (!isRecord(part)) continue;
|
if (!isRecord(part)) continue;
|
||||||
candidateAggregate.parts.push(cloneJsonValue(part));
|
appendPart(candidateAggregate.parts, part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,20 @@ export type GeminiReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'max';
|
|||||||
type GeminiThinkingConfig = {
|
type GeminiThinkingConfig = {
|
||||||
thinkingBudget?: number;
|
thinkingBudget?: number;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
|
includeThoughts?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GEMINI_3_STANDARD_BUDGETS: ReadonlyMap<number, GeminiReasoningEffort> = new Map([
|
||||||
|
[0, 'none'],
|
||||||
|
[1024, 'low'],
|
||||||
|
[8192, 'medium'],
|
||||||
|
[32768, 'high'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isGemini3Model(model: string): boolean {
|
||||||
|
return /gemini-3(?:[.-]|$)/i.test(model);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReasoningEffort(value: unknown): GeminiReasoningEffort {
|
function normalizeReasoningEffort(value: unknown): GeminiReasoningEffort {
|
||||||
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||||
if (
|
if (
|
||||||
@@ -43,10 +55,14 @@ function thinkingBudgetToReasoningEffort(budget: number): GeminiReasoningEffort
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldUseThinkingLevel(model: string, effort: GeminiReasoningEffort): boolean {
|
function shouldUseThinkingLevel(model: string, effort: GeminiReasoningEffort): boolean {
|
||||||
if (!/gemini-3-/i.test(model)) return false;
|
if (!isGemini3Model(model)) return false;
|
||||||
return effort === 'none' || effort === 'low' || effort === 'medium' || effort === 'high';
|
return effort === 'none' || effort === 'low' || effort === 'medium' || effort === 'high';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseThinkingLevelForBudget(model: string, budget: number): boolean {
|
||||||
|
return isGemini3Model(model) && GEMINI_3_STANDARD_BUDGETS.has(budget);
|
||||||
|
}
|
||||||
|
|
||||||
function reasoningEffortToThinkingBudget(effort: GeminiReasoningEffort): number {
|
function reasoningEffortToThinkingBudget(effort: GeminiReasoningEffort): number {
|
||||||
switch (effort) {
|
switch (effort) {
|
||||||
case 'none':
|
case 'none':
|
||||||
@@ -74,6 +90,23 @@ export function reasoningEffortToGeminiThinkingConfig(
|
|||||||
return { thinkingBudget: reasoningEffortToThinkingBudget(effort) };
|
return { thinkingBudget: reasoningEffortToThinkingBudget(effort) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function thinkingBudgetToGeminiThinkingConfig(
|
||||||
|
model: string,
|
||||||
|
thinkingBudget: unknown,
|
||||||
|
): GeminiThinkingConfig | null {
|
||||||
|
const budget = toFiniteInt(thinkingBudget);
|
||||||
|
if (budget === null) return null;
|
||||||
|
if (shouldUseThinkingLevelForBudget(model, budget)) {
|
||||||
|
const standardEffort = GEMINI_3_STANDARD_BUDGETS.get(budget);
|
||||||
|
return {
|
||||||
|
thinkingLevel: standardEffort ?? thinkingBudgetToReasoningEffort(budget),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
thinkingBudget: budget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function geminiThinkingConfigToReasoning(
|
export function geminiThinkingConfigToReasoning(
|
||||||
thinkingConfig: Record<string, unknown> | null | undefined,
|
thinkingConfig: Record<string, unknown> | null | undefined,
|
||||||
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
|
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
|
||||||
@@ -108,6 +141,16 @@ function toFiniteInt(value: unknown): number | null {
|
|||||||
return Math.max(0, Math.trunc(numeric));
|
return Math.max(0, Math.trunc(numeric));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withSynthesizedThoughts(
|
||||||
|
thinkingConfig: GeminiThinkingConfig | null,
|
||||||
|
): GeminiThinkingConfig | null {
|
||||||
|
if (!thinkingConfig) return null;
|
||||||
|
return {
|
||||||
|
...thinkingConfig,
|
||||||
|
includeThoughts: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveGeminiThinkingConfigFromRequest(
|
export function resolveGeminiThinkingConfigFromRequest(
|
||||||
model: string,
|
model: string,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
@@ -124,7 +167,7 @@ export function resolveGeminiThinkingConfigFromRequest(
|
|||||||
return { thinkingLevel: nativeLevel };
|
return { thinkingLevel: nativeLevel };
|
||||||
}
|
}
|
||||||
if (nativeBudget !== null) {
|
if (nativeBudget !== null) {
|
||||||
return { thinkingBudget: nativeBudget };
|
return thinkingBudgetToGeminiThinkingConfig(model, nativeBudget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +185,11 @@ export function resolveGeminiThinkingConfigFromRequest(
|
|||||||
|
|
||||||
const parsedBudget = toFiniteInt(reasoningBudget);
|
const parsedBudget = toFiniteInt(reasoningBudget);
|
||||||
if (parsedBudget !== null) {
|
if (parsedBudget !== null) {
|
||||||
return reasoningEffortToGeminiThinkingConfig(model, thinkingBudgetToReasoningEffort(parsedBudget));
|
return withSynthesizedThoughts(thinkingBudgetToGeminiThinkingConfig(model, parsedBudget));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reasoningEffort !== undefined) {
|
if (reasoningEffort !== undefined) {
|
||||||
return reasoningEffortToGeminiThinkingConfig(model, reasoningEffort);
|
return withSynthesizedThoughts(reasoningEffortToGeminiThinkingConfig(model, reasoningEffort));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -161,5 +204,13 @@ export function extractReasoningMetadataFromGeminiRequest(
|
|||||||
const thinkingConfig = isRecord(generationConfig?.thinkingConfig)
|
const thinkingConfig = isRecord(generationConfig?.thinkingConfig)
|
||||||
? generationConfig!.thinkingConfig as Record<string, unknown>
|
? generationConfig!.thinkingConfig as Record<string, unknown>
|
||||||
: null;
|
: null;
|
||||||
|
if (thinkingConfig) {
|
||||||
return geminiThinkingConfigToReasoning(thinkingConfig);
|
return geminiThinkingConfigToReasoning(thinkingConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
const synthesizedThinkingConfig = resolveGeminiThinkingConfigFromRequest(
|
||||||
|
typeof body.model === 'string' ? body.model : '',
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return geminiThinkingConfigToReasoning(synthesizedThinkingConfig ?? null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
type GeminiRecord = Record<string, unknown>;
|
type GeminiRecord = Record<string, unknown>;
|
||||||
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
import {
|
||||||
|
geminiThinkingConfigToReasoning,
|
||||||
|
resolveGeminiThinkingConfigFromRequest,
|
||||||
|
} from './convert.js';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is GeminiRecord {
|
function isRecord(value: unknown): value is GeminiRecord {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
@@ -30,6 +33,37 @@ function cloneContents(value: unknown): unknown[] | undefined {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneThinkingConfig(value: unknown): GeminiRecord | undefined {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
|
||||||
|
const next: GeminiRecord = {};
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (key === 'thinkingLevel') {
|
||||||
|
const normalizedLevel = typeof item === 'string' ? item.trim().toLowerCase() : '';
|
||||||
|
if (normalizedLevel === 'minimal') {
|
||||||
|
next.thinkingLevel = 'low';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (normalizedLevel) {
|
||||||
|
next.thinkingLevel = normalizedLevel;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'thinkingBudget') {
|
||||||
|
const numeric = typeof item === 'number' ? item : Number(item);
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
next.thinkingBudget = Math.max(0, Math.trunc(numeric));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next[key] = cloneJsonValue(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(next).length > 0 ? next : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function cloneGenerationConfig(value: unknown): GeminiRecord | undefined {
|
function cloneGenerationConfig(value: unknown): GeminiRecord | undefined {
|
||||||
if (!isRecord(value)) return undefined;
|
if (!isRecord(value)) return undefined;
|
||||||
const allowedKeys = [
|
const allowedKeys = [
|
||||||
@@ -52,7 +86,12 @@ function cloneGenerationConfig(value: unknown): GeminiRecord | undefined {
|
|||||||
];
|
];
|
||||||
const next: GeminiRecord = {};
|
const next: GeminiRecord = {};
|
||||||
for (const key of allowedKeys) {
|
for (const key of allowedKeys) {
|
||||||
if (value[key] !== undefined) next[key] = cloneJsonValue(value[key]);
|
if (value[key] === undefined) continue;
|
||||||
|
if (key === 'thinkingConfig') {
|
||||||
|
next[key] = cloneThinkingConfig(value[key]) ?? cloneJsonValue(value[key]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[key] = cloneJsonValue(value[key]);
|
||||||
}
|
}
|
||||||
return Object.keys(next).length > 0 ? next : undefined;
|
return Object.keys(next).length > 0 ? next : undefined;
|
||||||
}
|
}
|
||||||
@@ -71,6 +110,55 @@ function cloneTools(value: unknown): unknown[] | undefined {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulThinkingConfig(value: unknown): boolean {
|
||||||
|
return isRecord(value) && geminiThinkingConfigToReasoning(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeThinkingConfig(value: unknown): GeminiRecord | undefined {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
|
||||||
|
const next = cloneJsonValue(value);
|
||||||
|
const normalizedReasoning = geminiThinkingConfigToReasoning(next);
|
||||||
|
if (normalizedReasoning) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('thinkingLevel' in next) {
|
||||||
|
delete next.thinkingLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('thinkingBudget' in next) {
|
||||||
|
const numeric = typeof next.thinkingBudget === 'number' ? next.thinkingBudget : Number(next.thinkingBudget);
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
next.thinkingBudget = Math.max(0, Math.trunc(numeric));
|
||||||
|
} else {
|
||||||
|
delete next.thinkingBudget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(next).length > 0 ? next : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeThinkingConfig(
|
||||||
|
currentValue: unknown,
|
||||||
|
derivedThinkingConfig: GeminiRecord | null,
|
||||||
|
): GeminiRecord | undefined {
|
||||||
|
const currentThinkingConfig = sanitizeThinkingConfig(currentValue);
|
||||||
|
|
||||||
|
if (!derivedThinkingConfig) {
|
||||||
|
return currentThinkingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMeaningfulThinkingConfig(currentThinkingConfig)) {
|
||||||
|
return {
|
||||||
|
...(currentThinkingConfig ?? {}),
|
||||||
|
...cloneJsonValue(derivedThinkingConfig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentThinkingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
export type GeminiGenerateContentRequest = GeminiRecord;
|
export type GeminiGenerateContentRequest = GeminiRecord;
|
||||||
|
|
||||||
export const geminiGenerateContentInbound = {
|
export const geminiGenerateContentInbound = {
|
||||||
@@ -96,8 +184,12 @@ export const geminiGenerateContentInbound = {
|
|||||||
const generationConfig = isRecord(next.generationConfig)
|
const generationConfig = isRecord(next.generationConfig)
|
||||||
? { ...next.generationConfig }
|
? { ...next.generationConfig }
|
||||||
: {};
|
: {};
|
||||||
if (!isRecord(generationConfig.thinkingConfig)) {
|
const thinkingConfig = mergeThinkingConfig(
|
||||||
generationConfig.thinkingConfig = derivedThinkingConfig;
|
generationConfig.thinkingConfig,
|
||||||
|
derivedThinkingConfig,
|
||||||
|
);
|
||||||
|
if (thinkingConfig) {
|
||||||
|
generationConfig.thinkingConfig = thinkingConfig;
|
||||||
}
|
}
|
||||||
next.generationConfig = generationConfig;
|
next.generationConfig = generationConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import {
|
|||||||
} from './index.js';
|
} from './index.js';
|
||||||
import { extractGeminiUsage } from './usage.js';
|
import { extractGeminiUsage } from './usage.js';
|
||||||
import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js';
|
import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js';
|
||||||
|
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
||||||
|
import {
|
||||||
|
parseGeminiStreamPayload,
|
||||||
|
serializeAggregateJsonPayload,
|
||||||
|
serializeAggregatePayload,
|
||||||
|
} from './stream.js';
|
||||||
|
|
||||||
describe('geminiGenerateContentTransformer.inbound', () => {
|
describe('geminiGenerateContentTransformer.inbound', () => {
|
||||||
it('preserves native Gemini request fields through normalization', () => {
|
it('preserves native Gemini request fields through normalization', () => {
|
||||||
@@ -96,6 +102,130 @@ describe('geminiGenerateContentTransformer.inbound', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses thinkingLevel for Gemini 3 reasoning budgets within standard ranges and preserves very high budgets', () => {
|
||||||
|
const standardBudget = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
generationConfig: {},
|
||||||
|
reasoning_budget: 1024,
|
||||||
|
},
|
||||||
|
'gemini-3-pro',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(standardBudget.generationConfig).toEqual({
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: 'low',
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const higherBudget = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
generationConfig: {},
|
||||||
|
reasoning: {
|
||||||
|
budget_tokens: 12000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'gemini-3-pro',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(higherBudget.generationConfig).toEqual({
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingBudget: 12000,
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers thinkingLevel for Gemini 3.1 standard reasoning budgets and preserves non-standard budgets', () => {
|
||||||
|
const standardBudgetBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
generationConfig: {},
|
||||||
|
reasoning: {
|
||||||
|
budget_tokens: 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'gemini-3.1-pro-preview',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(standardBudgetBody.generationConfig).toEqual({
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: 'low',
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nonStandardBudgetBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
generationConfig: {},
|
||||||
|
reasoning: {
|
||||||
|
budget_tokens: 50000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'gemini-3-flash',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nonStandardBudgetBody.generationConfig).toEqual({
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingBudget: 50000,
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes native thinking config and keeps request metadata stable when injecting derived thinking config', () => {
|
||||||
|
const body = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
model: 'gemini-3-pro',
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
systemInstruction: {
|
||||||
|
role: 'system',
|
||||||
|
parts: [{ text: 'system prompt' }],
|
||||||
|
},
|
||||||
|
cachedContent: 'cached/abc',
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['TEXT', 'AUDIO'],
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: 'minimal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tools: [
|
||||||
|
{ googleSearch: {} },
|
||||||
|
{ urlContext: {} },
|
||||||
|
{ codeExecution: {} },
|
||||||
|
],
|
||||||
|
reasoning_effort: 'high',
|
||||||
|
reasoning_budget: 4096,
|
||||||
|
},
|
||||||
|
'gemini-3-pro',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body.systemInstruction).toEqual({
|
||||||
|
role: 'system',
|
||||||
|
parts: [{ text: 'system prompt' }],
|
||||||
|
});
|
||||||
|
expect(body.cachedContent).toBe('cached/abc');
|
||||||
|
expect(body.tools).toEqual([
|
||||||
|
{ googleSearch: {} },
|
||||||
|
{ urlContext: {} },
|
||||||
|
{ codeExecution: {} },
|
||||||
|
]);
|
||||||
|
expect(body.generationConfig).toEqual({
|
||||||
|
responseModalities: ['TEXT', 'AUDIO'],
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: 'low',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(body.reasoning_effort).toBeUndefined();
|
||||||
|
expect(body.reasoning_budget).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('geminiGenerateContentTransformer.aggregator', () => {
|
describe('geminiGenerateContentTransformer.aggregator', () => {
|
||||||
@@ -302,6 +432,127 @@ describe('geminiGenerateContentTransformer.aggregator', () => {
|
|||||||
{ codeExecution: {} },
|
{ codeExecution: {} },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves tool thought signatures and request-side Gemini metadata in outbound helpers', () => {
|
||||||
|
const state = geminiGenerateContentTransformer.aggregator.createState();
|
||||||
|
geminiGenerateContentTransformer.aggregator.apply(state, {
|
||||||
|
responseId: 'resp-tools',
|
||||||
|
modelVersion: 'gemini-3-pro',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
id: 'call-1',
|
||||||
|
name: 'lookup',
|
||||||
|
args: { query: 'cat' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'tool-sig-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/cat' }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 12,
|
||||||
|
candidatesTokenCount: 4,
|
||||||
|
totalTokenCount: 18,
|
||||||
|
cachedContentTokenCount: 6,
|
||||||
|
thoughtsTokenCount: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||||
|
responseId: 'resp-tools',
|
||||||
|
modelVersion: 'gemini-3-pro',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
id: 'call-1',
|
||||||
|
name: 'lookup',
|
||||||
|
args: { query: 'cat' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'tool-sig-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/cat' }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 12,
|
||||||
|
candidatesTokenCount: 4,
|
||||||
|
totalTokenCount: 18,
|
||||||
|
cachedContentTokenCount: 6,
|
||||||
|
thoughtsTokenCount: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(extractResponseMetadata(state, {
|
||||||
|
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
|
||||||
|
cachedContent: 'cached/tooling',
|
||||||
|
safetySettings: [{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }],
|
||||||
|
toolConfig: {
|
||||||
|
functionCallingConfig: {
|
||||||
|
mode: 'ANY',
|
||||||
|
allowedFunctionNames: ['lookup'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
responseSchema: { type: 'object' },
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
thinkingConfig: { thinkingLevel: 'high' },
|
||||||
|
imageConfig: { aspectRatio: '1:1' },
|
||||||
|
},
|
||||||
|
tools: [
|
||||||
|
{ functionDeclarations: [{ name: 'lookup', description: 'find facts' }] },
|
||||||
|
{ googleSearch: {} },
|
||||||
|
],
|
||||||
|
})).toEqual({
|
||||||
|
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
|
||||||
|
cachedContent: 'cached/tooling',
|
||||||
|
safetySettings: [{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }],
|
||||||
|
toolConfig: {
|
||||||
|
functionCallingConfig: {
|
||||||
|
mode: 'ANY',
|
||||||
|
allowedFunctionNames: ['lookup'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
responseSchema: { type: 'object' },
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
thinkingConfig: { thinkingLevel: 'high' },
|
||||||
|
imageConfig: { aspectRatio: '1:1' },
|
||||||
|
tools: [
|
||||||
|
{ functionDeclarations: [{ name: 'lookup', description: 'find facts' }] },
|
||||||
|
{ googleSearch: {} },
|
||||||
|
],
|
||||||
|
citations: [{ citations: [{ uri: 'https://example.com/cat' }] }],
|
||||||
|
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||||
|
thoughtSignature: 'tool-sig-1',
|
||||||
|
thoughtSignatures: ['tool-sig-1'],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 12,
|
||||||
|
candidatesTokenCount: 4,
|
||||||
|
totalTokenCount: 18,
|
||||||
|
cachedContentTokenCount: 6,
|
||||||
|
thoughtsTokenCount: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractGeminiUsage', () => {
|
describe('extractGeminiUsage', () => {
|
||||||
@@ -350,6 +601,44 @@ describe('Gemini reasoning mapping', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses thinkingLevel for Gemini 3 budget values up to the high tier and preserves very high budgets', () => {
|
||||||
|
expect(resolveGeminiThinkingConfigFromRequest('gemini-3-pro', {
|
||||||
|
reasoning_budget: 8192,
|
||||||
|
})).toEqual({
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
includeThoughts: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveGeminiThinkingConfigFromRequest('gemini-3-pro', {
|
||||||
|
reasoning_budget: 12000,
|
||||||
|
})).toEqual({
|
||||||
|
thinkingBudget: 12000,
|
||||||
|
includeThoughts: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects derived thinking config into normalized requests when runtime model name is provided', () => {
|
||||||
|
const body = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
thinkingConfig: {},
|
||||||
|
},
|
||||||
|
reasoning_effort: 'high',
|
||||||
|
},
|
||||||
|
'gemini-3-pro',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body.generationConfig).toEqual({
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: 'high',
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('maps Gemini thinking config back to normalized reasoning hints', () => {
|
it('maps Gemini thinking config back to normalized reasoning hints', () => {
|
||||||
expect(geminiThinkingConfigToReasoning({
|
expect(geminiThinkingConfigToReasoning({
|
||||||
thinkingLevel: 'medium',
|
thinkingLevel: 'medium',
|
||||||
@@ -365,6 +654,132 @@ describe('Gemini reasoning mapping', () => {
|
|||||||
reasoningBudget: 512,
|
reasoningBudget: 512,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses thinkingLevel for Gemini 3 models when reasoning_budget is a standard tier', () => {
|
||||||
|
const normalized = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
reasoning_budget: 8192,
|
||||||
|
},
|
||||||
|
'gemini-3-flash',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized.generationConfig).toEqual({
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-standard Gemini 3 reasoning_budget values instead of coercing them to a tier', () => {
|
||||||
|
const normalized = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||||
|
reasoning_budget: 5000,
|
||||||
|
},
|
||||||
|
'gemini-3-flash',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized.generationConfig).toEqual({
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingBudget: 5000,
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills missing native thinkingConfig from reasoning_effort while preserving request metadata', () => {
|
||||||
|
const normalized = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
cachedContent: 'cached/abc',
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
thinkingConfig: {
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reasoning_effort: 'high',
|
||||||
|
},
|
||||||
|
'gemini-3-flash',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized.cachedContent).toBe('cached/abc');
|
||||||
|
expect(normalized.generationConfig).toEqual({
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
thinkingConfig: {
|
||||||
|
includeThoughts: true,
|
||||||
|
thinkingLevel: 'high',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-standard explicit Gemini 3 budgets instead of coercing them to a standard effort level', () => {
|
||||||
|
expect(
|
||||||
|
geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
reasoning_budget: 50000,
|
||||||
|
},
|
||||||
|
'gemini-3-pro',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
generationConfig: {
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingBudget: 50000,
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Gemini convert/inbound runtime strategy', () => {
|
||||||
|
it('uses thinkingLevel only for standard Gemini 3 budget tiers and preserves non-standard budgets', () => {
|
||||||
|
expect(resolveGeminiThinkingConfigFromRequest('gemini-3-pro', {
|
||||||
|
reasoning_budget: 1024,
|
||||||
|
})).toEqual({
|
||||||
|
thinkingLevel: 'low',
|
||||||
|
includeThoughts: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveGeminiThinkingConfigFromRequest('gemini-3-pro', {
|
||||||
|
reasoning_budget: 512,
|
||||||
|
})).toEqual({
|
||||||
|
thinkingBudget: 512,
|
||||||
|
includeThoughts: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the runtime model name when injecting thinkingConfig and keeps request metadata stable', () => {
|
||||||
|
const normalized = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
|
{
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
cachedContent: 'cached/runtime',
|
||||||
|
systemInstruction: {
|
||||||
|
role: 'system',
|
||||||
|
parts: [{ text: 'system prompt' }],
|
||||||
|
},
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
},
|
||||||
|
reasoning_budget: 512,
|
||||||
|
},
|
||||||
|
'gemini-3-pro',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized.cachedContent).toBe('cached/runtime');
|
||||||
|
expect(normalized.systemInstruction).toEqual({
|
||||||
|
role: 'system',
|
||||||
|
parts: [{ text: 'system prompt' }],
|
||||||
|
});
|
||||||
|
expect(normalized.generationConfig).toEqual({
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingBudget: 512,
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('geminiGenerateContentTransformer.stream', () => {
|
describe('geminiGenerateContentTransformer.stream', () => {
|
||||||
@@ -436,4 +851,91 @@ describe('geminiGenerateContentTransformer.stream', () => {
|
|||||||
extractGeminiUsage(jsonState),
|
extractGeminiUsage(jsonState),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes SSE and JSON-array payloads through the same parse/apply boundary', () => {
|
||||||
|
const chunks = [
|
||||||
|
{
|
||||||
|
responseId: 'resp-boundary',
|
||||||
|
modelVersion: 'gemini-2.5-pro',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'hello', thought: true, thoughtSignature: 'sig-a' }],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://example.com/a' }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseId: 'resp-boundary',
|
||||||
|
modelVersion: 'gemini-2.5-pro',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'world' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 10,
|
||||||
|
candidatesTokenCount: 4,
|
||||||
|
totalTokenCount: 14,
|
||||||
|
cachedContentTokenCount: 3,
|
||||||
|
thoughtsTokenCount: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const sseInput = chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join('');
|
||||||
|
const parsedSse = parseGeminiStreamPayload(sseInput, 'text/event-stream');
|
||||||
|
const parsedJson = parseGeminiStreamPayload(chunks, 'application/json');
|
||||||
|
|
||||||
|
expect(parsedSse.events).toEqual(parsedJson.events);
|
||||||
|
expect(parsedSse.rest).toBe('');
|
||||||
|
expect(parsedJson.rest).toBe('');
|
||||||
|
|
||||||
|
const sseState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||||
|
const jsonState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||||
|
geminiGenerateContentTransformer.stream.applyParsedPayloadToAggregate(sseState, parsedSse);
|
||||||
|
geminiGenerateContentTransformer.stream.applyParsedPayloadToAggregate(jsonState, parsedJson);
|
||||||
|
|
||||||
|
expect(serializeGeminiAggregateResponse(sseState)).toEqual(
|
||||||
|
serializeGeminiAggregateResponse(jsonState),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes aggregate payloads with identical final semantics for SSE and JSON modes', () => {
|
||||||
|
const state = geminiGenerateContentTransformer.aggregator.createState();
|
||||||
|
geminiGenerateContentTransformer.aggregator.apply(state, {
|
||||||
|
responseId: 'resp-serialize',
|
||||||
|
modelVersion: 'gemini-2.5-pro',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'answer', thoughtSignature: 'sig-final' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 9,
|
||||||
|
candidatesTokenCount: 5,
|
||||||
|
totalTokenCount: 14,
|
||||||
|
cachedContentTokenCount: 4,
|
||||||
|
thoughtsTokenCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonPayload = serializeAggregateJsonPayload(state);
|
||||||
|
const ssePayload = serializeAggregatePayload(state, 'sse');
|
||||||
|
const reparsedSse = parseGeminiStreamPayload(ssePayload, 'text/event-stream');
|
||||||
|
|
||||||
|
expect(jsonPayload).toEqual(serializeGeminiAggregateResponse(state));
|
||||||
|
expect(reparsedSse.events).toEqual([jsonPayload]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ function isRecord(value: unknown): value is GeminiRecord {
|
|||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
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 {
|
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
|
||||||
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
|
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
|
||||||
}
|
}
|
||||||
@@ -112,18 +124,92 @@ function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiReco
|
|||||||
return [fallback];
|
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 extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
||||||
if (!isRecord(requestPayload)) return {};
|
if (!isRecord(requestPayload)) return {};
|
||||||
|
|
||||||
const metadata: GeminiRecord = {};
|
const metadata: GeminiRecord = {};
|
||||||
if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = requestPayload.systemInstruction;
|
if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||||
if (requestPayload.cachedContent !== undefined) metadata.cachedContent = requestPayload.cachedContent;
|
if (requestPayload.cachedContent !== undefined) metadata.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||||
|
if (requestPayload.safetySettings !== undefined) metadata.safetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||||
|
if (requestPayload.toolConfig !== undefined) metadata.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||||
|
|
||||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||||
if (generationConfig) {
|
if (generationConfig) {
|
||||||
if (generationConfig.responseModalities !== undefined) metadata.responseModalities = generationConfig.responseModalities;
|
const preservedKeys = [
|
||||||
if (generationConfig.responseSchema !== undefined) metadata.responseSchema = generationConfig.responseSchema;
|
'stopSequences',
|
||||||
if (generationConfig.responseMimeType !== undefined) metadata.responseMimeType = generationConfig.responseMimeType;
|
'responseModalities',
|
||||||
|
'responseMimeType',
|
||||||
|
'responseSchema',
|
||||||
|
'candidateCount',
|
||||||
|
'maxOutputTokens',
|
||||||
|
'temperature',
|
||||||
|
'topP',
|
||||||
|
'topK',
|
||||||
|
'presencePenalty',
|
||||||
|
'frequencyPenalty',
|
||||||
|
'seed',
|
||||||
|
'responseLogprobs',
|
||||||
|
'logprobs',
|
||||||
|
'thinkingConfig',
|
||||||
|
'imageConfig',
|
||||||
|
];
|
||||||
|
for (const key of preservedKeys) {
|
||||||
|
if (generationConfig[key] !== undefined) {
|
||||||
|
metadata[key] = cloneJsonValue(generationConfig[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(requestPayload.tools)) {
|
if (Array.isArray(requestPayload.tools)) {
|
||||||
@@ -131,10 +217,10 @@ function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
|||||||
.filter((item) => isRecord(item))
|
.filter((item) => isRecord(item))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const next: GeminiRecord = {};
|
const next: GeminiRecord = {};
|
||||||
if (item.googleSearch !== undefined) next.googleSearch = item.googleSearch;
|
if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch);
|
||||||
if (item.urlContext !== undefined) next.urlContext = item.urlContext;
|
if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext);
|
||||||
if (item.codeExecution !== undefined) next.codeExecution = item.codeExecution;
|
if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution);
|
||||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = item.functionDeclarations;
|
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations);
|
||||||
return next;
|
return next;
|
||||||
})
|
})
|
||||||
.filter((item) => Object.keys(item).length > 0);
|
.filter((item) => Object.keys(item).length > 0);
|
||||||
@@ -169,11 +255,17 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno
|
|||||||
const state = ensureAggregateState(payload);
|
const state = ensureAggregateState(payload);
|
||||||
const metadata: GeminiRecord = extractRequestSemantics(requestPayload);
|
const metadata: GeminiRecord = extractRequestSemantics(requestPayload);
|
||||||
|
|
||||||
if (state.citations.length > 0) metadata.citations = state.citations;
|
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
|
||||||
if (state.groundingMetadata.length > 0) metadata.groundingMetadata = state.groundingMetadata;
|
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
|
||||||
if (state.thoughtSignatures.length > 0) {
|
const thoughtSignatures = extractOrderedThoughtSignatures(state);
|
||||||
metadata.thoughtSignature = state.thoughtSignatures[0];
|
|
||||||
metadata.thoughtSignatures = state.thoughtSignatures;
|
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);
|
const usageMetadata = buildUsageMetadata(state);
|
||||||
if (usageMetadata) metadata.usageMetadata = usageMetadata;
|
if (usageMetadata) metadata.usageMetadata = usageMetadata;
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createGeminiGenerateContentAggregateState } from './aggregator.js';
|
||||||
|
import { extractResponseMetadata, serializeGeminiAggregateResponse } from './outbound.js';
|
||||||
|
import {
|
||||||
|
applyJsonPayloadToAggregate,
|
||||||
|
applySsePayloadsToAggregate,
|
||||||
|
geminiGenerateContentStream,
|
||||||
|
parseGeminiStreamPayload,
|
||||||
|
serializeAggregateSsePayload,
|
||||||
|
} from './stream.js';
|
||||||
|
import { extractGeminiUsage } from './usage.js';
|
||||||
|
|
||||||
|
describe('geminiGenerateContentStream', () => {
|
||||||
|
it('treats stringified json payloads as json when content type is not sse', () => {
|
||||||
|
const payload = JSON.stringify([
|
||||||
|
{
|
||||||
|
responseId: 'resp-json-string',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'json-string-body' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(parseGeminiStreamPayload(payload, 'application/json')).toEqual({
|
||||||
|
format: 'json',
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
responseId: 'resp-json-string',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'json-string-body' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rest: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates SSE and JSON-array payloads into the same final semantics', () => {
|
||||||
|
const requestPayload = {
|
||||||
|
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
|
||||||
|
cachedContent: 'cached/abc',
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['TEXT'],
|
||||||
|
responseSchema: { type: 'object' },
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
},
|
||||||
|
tools: [
|
||||||
|
{ googleSearch: {} },
|
||||||
|
{ codeExecution: {} },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunks = [
|
||||||
|
{
|
||||||
|
responseId: 'resp-1',
|
||||||
|
modelVersion: 'gemini-2.5-pro',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' }],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://a.example.com' }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'alt-answer' }],
|
||||||
|
},
|
||||||
|
groundingMetadata: { webSearchQueries: ['dog'] },
|
||||||
|
citationMetadata: { citations: [{ uri: 'https://b.example.com' }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseId: 'resp-1',
|
||||||
|
modelVersion: 'gemini-2.5-pro',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'answer' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: 'MAX_TOKENS',
|
||||||
|
content: {
|
||||||
|
parts: [{ functionCall: { id: 'tool-1', name: 'lookup', args: { q: 'cat' } }, thoughtSignature: 'sig-tool-1' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 10,
|
||||||
|
candidatesTokenCount: 4,
|
||||||
|
totalTokenCount: 16,
|
||||||
|
cachedContentTokenCount: 3,
|
||||||
|
thoughtsTokenCount: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ssePayload = `${chunks
|
||||||
|
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
.join('')}data: [DONE]\n\n`;
|
||||||
|
|
||||||
|
const sseState = createGeminiGenerateContentAggregateState();
|
||||||
|
const sseResult = applySsePayloadsToAggregate(sseState, ssePayload);
|
||||||
|
|
||||||
|
const jsonState = createGeminiGenerateContentAggregateState();
|
||||||
|
applyJsonPayloadToAggregate(jsonState, chunks);
|
||||||
|
|
||||||
|
expect(sseResult.rest).toBe('');
|
||||||
|
expect(serializeGeminiAggregateResponse(sseResult.state)).toEqual(
|
||||||
|
serializeGeminiAggregateResponse(jsonState),
|
||||||
|
);
|
||||||
|
expect(extractResponseMetadata(sseResult.state, requestPayload)).toEqual(
|
||||||
|
extractResponseMetadata(jsonState, requestPayload),
|
||||||
|
);
|
||||||
|
expect(extractGeminiUsage(sseResult.state)).toEqual(
|
||||||
|
extractGeminiUsage(jsonState),
|
||||||
|
);
|
||||||
|
expect(serializeAggregateSsePayload(sseResult.state)).toBe(
|
||||||
|
`data: ${JSON.stringify(serializeGeminiAggregateResponse(jsonState))}\n\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps trailing rest for partial SSE blocks and ignores done markers', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
const payload = [
|
||||||
|
'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]}}]}',
|
||||||
|
'',
|
||||||
|
'data: [DONE]',
|
||||||
|
'',
|
||||||
|
'data: {"responseId":"resp-1"',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = applySsePayloadsToAggregate(state, payload);
|
||||||
|
|
||||||
|
expect(result.rest).toBe('data: {"responseId":"resp-1"');
|
||||||
|
expect(serializeGeminiAggregateResponse(result.state)).toEqual({
|
||||||
|
responseId: 'resp-1',
|
||||||
|
modelVersion: '',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'hello' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes object payloads through the JSON-array path', () => {
|
||||||
|
const state = createGeminiGenerateContentAggregateState();
|
||||||
|
|
||||||
|
applyJsonPayloadToAggregate(state, {
|
||||||
|
responseId: 'resp-2',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'single-object' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(geminiGenerateContentStream.parseJsonArrayPayload({
|
||||||
|
responseId: 'resp-2',
|
||||||
|
})).toEqual([{ responseId: 'resp-2' }]);
|
||||||
|
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||||
|
responseId: 'resp-2',
|
||||||
|
modelVersion: '',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: 'STOP',
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'single-object' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,12 +3,25 @@ import {
|
|||||||
createGeminiGenerateContentAggregateState,
|
createGeminiGenerateContentAggregateState,
|
||||||
type GeminiGenerateContentAggregateState,
|
type GeminiGenerateContentAggregateState,
|
||||||
} from './aggregator.js';
|
} from './aggregator.js';
|
||||||
|
import { serializeGeminiAggregateResponse } from './outbound.js';
|
||||||
|
|
||||||
type ParsedSsePayloads = {
|
type ParsedSsePayloads = {
|
||||||
events: unknown[];
|
events: unknown[];
|
||||||
rest: string;
|
rest: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GeminiGenerateContentStreamFormat = 'sse' | 'json';
|
||||||
|
|
||||||
|
type ParsedGeminiStreamPayload = {
|
||||||
|
format: GeminiGenerateContentStreamFormat;
|
||||||
|
events: unknown[];
|
||||||
|
rest: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppliedGeminiStreamPayloads = ParsedSsePayloads & {
|
||||||
|
state: GeminiGenerateContentAggregateState;
|
||||||
|
};
|
||||||
|
|
||||||
function serializeSsePayload(payload: unknown): string {
|
function serializeSsePayload(payload: unknown): string {
|
||||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
return `data: ${JSON.stringify(payload)}\n\n`;
|
||||||
}
|
}
|
||||||
@@ -45,17 +58,157 @@ function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
|||||||
return { events, rest };
|
return { events, rest };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 {
|
||||||
|
events: parsed.events,
|
||||||
|
rest: parsed.rest,
|
||||||
|
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.events.map((event) => serializeAggregateSsePayload(event)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return parseJsonArrayPayload(payload).map((event) => {
|
||||||
|
applyGeminiGenerateContentAggregate(state, event);
|
||||||
|
return serializeAggregateJsonPayload(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyJsonPayloadToAggregate(state, payload);
|
||||||
|
return serializeAggregateJsonPayload(state);
|
||||||
|
}
|
||||||
|
|
||||||
export const geminiGenerateContentStream = {
|
export const geminiGenerateContentStream = {
|
||||||
isSseContentType(contentType: string | null | undefined): boolean {
|
isSseContentType(contentType: string | null | undefined): boolean {
|
||||||
return (contentType || '').toLowerCase().includes('text/event-stream');
|
return (contentType || '').toLowerCase().includes('text/event-stream');
|
||||||
},
|
},
|
||||||
|
|
||||||
parseJsonArrayPayload(payload: unknown): unknown[] {
|
parseJsonArrayPayload,
|
||||||
return Array.isArray(payload) ? payload : [];
|
parseGeminiStreamPayload,
|
||||||
},
|
|
||||||
|
|
||||||
parseSsePayloads,
|
parseSsePayloads,
|
||||||
serializeSsePayload,
|
serializeSsePayload,
|
||||||
|
serializeAggregateJsonPayload,
|
||||||
|
serializeAggregatePayload,
|
||||||
|
serializeAggregateSsePayload,
|
||||||
|
serializeUpstreamJsonPayload,
|
||||||
|
applyParsedPayloadToAggregate,
|
||||||
|
applyJsonPayloadToAggregate,
|
||||||
|
applySsePayloadsToAggregate,
|
||||||
|
consumeUpstreamSseBuffer,
|
||||||
|
|
||||||
createAggregateState(): GeminiGenerateContentAggregateState {
|
createAggregateState(): GeminiGenerateContentAggregateState {
|
||||||
return createGeminiGenerateContentAggregateState();
|
return createGeminiGenerateContentAggregateState();
|
||||||
@@ -65,3 +218,24 @@ export const geminiGenerateContentStream = {
|
|||||||
return applyGeminiGenerateContentAggregate(state, payload);
|
return applyGeminiGenerateContentAggregate(state, payload);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
applyParsedPayloadToAggregate,
|
||||||
|
applyJsonPayloadToAggregate,
|
||||||
|
applySsePayloadsToAggregate,
|
||||||
|
parseGeminiStreamPayload,
|
||||||
|
parseJsonArrayPayload,
|
||||||
|
parseSsePayloads,
|
||||||
|
serializeAggregateJsonPayload,
|
||||||
|
serializeAggregatePayload,
|
||||||
|
serializeAggregateSsePayload,
|
||||||
|
serializeUpstreamJsonPayload,
|
||||||
|
serializeSsePayload,
|
||||||
|
consumeUpstreamSseBuffer,
|
||||||
|
};
|
||||||
|
export type {
|
||||||
|
AppliedGeminiStreamPayloads,
|
||||||
|
GeminiGenerateContentStreamFormat,
|
||||||
|
ParsedGeminiStreamPayload,
|
||||||
|
ParsedSsePayloads,
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ function isAggregateState(value: unknown): value is GeminiGenerateContentAggrega
|
|||||||
return isRecord(value) && Array.isArray(value.parts) && isRecord(value.usage);
|
return isRecord(value) && Array.isArray(value.parts) && isRecord(value.usage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function extractGeminiUsage(payload: unknown): NormalizedUsage {
|
export function extractGeminiUsage(payload: unknown): NormalizedUsage {
|
||||||
const usageMetadata = isAggregateState(payload)
|
const usageMetadata = isAggregateState(payload)
|
||||||
? payload.usage
|
? payload.usage
|
||||||
@@ -20,22 +24,12 @@ export function extractGeminiUsage(payload: unknown): NormalizedUsage {
|
|||||||
return createEmptyNormalizedUsage();
|
return createEmptyNormalizedUsage();
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptTokens = typeof usageMetadata.promptTokenCount === 'number'
|
const promptTokens = finiteNumber(usageMetadata.promptTokenCount) ?? 0;
|
||||||
? usageMetadata.promptTokenCount
|
const candidatesTokenCount = finiteNumber(usageMetadata.candidatesTokenCount) ?? 0;
|
||||||
: 0;
|
const thoughtsTokenCount = finiteNumber(usageMetadata.thoughtsTokenCount) ?? 0;
|
||||||
const candidatesTokenCount = typeof usageMetadata.candidatesTokenCount === 'number'
|
|
||||||
? usageMetadata.candidatesTokenCount
|
|
||||||
: 0;
|
|
||||||
const thoughtsTokenCount = typeof usageMetadata.thoughtsTokenCount === 'number'
|
|
||||||
? usageMetadata.thoughtsTokenCount
|
|
||||||
: 0;
|
|
||||||
const completionTokens = candidatesTokenCount + thoughtsTokenCount;
|
const completionTokens = candidatesTokenCount + thoughtsTokenCount;
|
||||||
const totalTokens = typeof usageMetadata.totalTokenCount === 'number'
|
const totalTokens = finiteNumber(usageMetadata.totalTokenCount) ?? (promptTokens + completionTokens);
|
||||||
? usageMetadata.totalTokenCount
|
const cachedTokens = finiteNumber(usageMetadata.cachedContentTokenCount) ?? 0;
|
||||||
: promptTokens + completionTokens;
|
|
||||||
const cachedTokens = typeof usageMetadata.cachedContentTokenCount === 'number'
|
|
||||||
? usageMetadata.cachedContentTokenCount
|
|
||||||
: 0;
|
|
||||||
const reasoningTokens = thoughtsTokenCount;
|
const reasoningTokens = thoughtsTokenCount;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent } from '../../shared/normalized.js';
|
import { type NormalizedFinalResponse, type NormalizedStreamEvent } from '../../shared/normalized.js';
|
||||||
import type {
|
import type {
|
||||||
|
OpenAiChatChoice,
|
||||||
|
OpenAiChatChoiceDelta,
|
||||||
OpenAiChatNormalizedFinalResponse,
|
OpenAiChatNormalizedFinalResponse,
|
||||||
OpenAiChatNormalizedStreamEvent,
|
OpenAiChatNormalizedStreamEvent,
|
||||||
OpenAiChatUsageDetails,
|
OpenAiChatUsageDetails,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
|
import { mergeChatUsageDetails } from './helpers.js';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
@@ -22,18 +25,9 @@ function pushUniqueAnnotation(
|
|||||||
annotations.push(candidate);
|
annotations.push(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeUsageDetails(
|
type ChoiceAggregate = {
|
||||||
target: OpenAiChatUsageDetails | undefined,
|
index: number;
|
||||||
next: OpenAiChatUsageDetails | undefined,
|
role?: 'assistant';
|
||||||
): OpenAiChatUsageDetails | undefined {
|
|
||||||
if (!target && !next) return undefined;
|
|
||||||
return {
|
|
||||||
...(target || {}),
|
|
||||||
...(next || {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpenAiChatAggregateState = {
|
|
||||||
content: string[];
|
content: string[];
|
||||||
reasoning: string[];
|
reasoning: string[];
|
||||||
toolCalls: Array<{ id: string; name: string; arguments: string }>;
|
toolCalls: Array<{ id: string; name: string; arguments: string }>;
|
||||||
@@ -41,11 +35,18 @@ export type OpenAiChatAggregateState = {
|
|||||||
annotations: Array<Record<string, unknown>>;
|
annotations: Array<Record<string, unknown>>;
|
||||||
annotationUrls: Set<string>;
|
annotationUrls: Set<string>;
|
||||||
citations: Set<string>;
|
citations: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenAiChatAggregateState = {
|
||||||
|
choices: Map<number, ChoiceAggregate>;
|
||||||
|
citations: Set<string>;
|
||||||
usageDetails?: OpenAiChatUsageDetails;
|
usageDetails?: OpenAiChatUsageDetails;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
function createChoiceAggregate(index: number): ChoiceAggregate {
|
||||||
return {
|
return {
|
||||||
|
index,
|
||||||
|
role: undefined,
|
||||||
content: [],
|
content: [],
|
||||||
reasoning: [],
|
reasoning: [],
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
@@ -53,6 +54,53 @@ export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
|||||||
annotations: [],
|
annotations: [],
|
||||||
annotationUrls: new Set<string>(),
|
annotationUrls: new Set<string>(),
|
||||||
citations: new Set<string>(),
|
citations: new Set<string>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChoiceAggregate(state: OpenAiChatAggregateState, index: number): ChoiceAggregate {
|
||||||
|
const existing = state.choices.get(index);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = createChoiceAggregate(index);
|
||||||
|
state.choices.set(index, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChoiceDelta(choice: ChoiceAggregate, event: OpenAiChatChoiceDelta) {
|
||||||
|
if (event.role) choice.role = event.role;
|
||||||
|
if (event.contentDelta) choice.content.push(event.contentDelta);
|
||||||
|
if (event.reasoningDelta) choice.reasoning.push(event.reasoningDelta);
|
||||||
|
if (event.finishReason !== undefined) choice.finishReason = event.finishReason ?? null;
|
||||||
|
if (Array.isArray(event.toolCallDeltas)) {
|
||||||
|
for (const delta of event.toolCallDeltas) {
|
||||||
|
if (!delta.id && !delta.name && !delta.argumentsDelta) continue;
|
||||||
|
const index = Number.isFinite(delta.index) ? Math.max(0, Math.trunc(delta.index)) : choice.toolCalls.length;
|
||||||
|
while (choice.toolCalls.length <= index) {
|
||||||
|
choice.toolCalls.push({ id: '', name: '', arguments: '' });
|
||||||
|
}
|
||||||
|
const existing = choice.toolCalls[index];
|
||||||
|
if (delta.id) existing.id = delta.id;
|
||||||
|
if (delta.name) existing.name = delta.name;
|
||||||
|
if (delta.argumentsDelta) existing.arguments += delta.argumentsDelta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(event.annotations)) {
|
||||||
|
for (const annotation of event.annotations) {
|
||||||
|
pushUniqueAnnotation(choice.annotations, choice.annotationUrls, annotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(event.citations)) {
|
||||||
|
for (const citation of event.citations) {
|
||||||
|
if (typeof citation === 'string' && citation.trim()) {
|
||||||
|
choice.citations.add(citation.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
||||||
|
return {
|
||||||
|
choices: new Map<number, ChoiceAggregate>(),
|
||||||
|
citations: new Set<string>(),
|
||||||
usageDetails: undefined,
|
usageDetails: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -61,35 +109,34 @@ export function applyOpenAiChatStreamEvent(
|
|||||||
state: OpenAiChatAggregateState,
|
state: OpenAiChatAggregateState,
|
||||||
event: OpenAiChatNormalizedStreamEvent,
|
event: OpenAiChatNormalizedStreamEvent,
|
||||||
): OpenAiChatAggregateState {
|
): OpenAiChatAggregateState {
|
||||||
if (event.contentDelta) state.content.push(event.contentDelta);
|
const choiceEvents = Array.isArray(event.choiceEvents) && event.choiceEvents.length > 0
|
||||||
if (event.reasoningDelta) state.reasoning.push(event.reasoningDelta);
|
? event.choiceEvents
|
||||||
if (event.finishReason !== undefined) state.finishReason = event.finishReason ?? null;
|
: [{
|
||||||
if (Array.isArray(event.toolCallDeltas)) {
|
index: Number.isFinite(event.choiceIndex) ? Math.max(0, Math.trunc(event.choiceIndex!)) : 0,
|
||||||
for (const delta of event.toolCallDeltas) {
|
role: event.role,
|
||||||
if (!delta.id && !delta.name && !delta.argumentsDelta) continue;
|
contentDelta: event.contentDelta,
|
||||||
const index = Number.isFinite(delta.index) ? Math.max(0, Math.trunc(delta.index)) : state.toolCalls.length;
|
reasoningDelta: event.reasoningDelta,
|
||||||
while (state.toolCalls.length <= index) {
|
toolCallDeltas: event.toolCallDeltas,
|
||||||
state.toolCalls.push({ id: '', name: '', arguments: '' });
|
finishReason: event.finishReason,
|
||||||
}
|
annotations: event.annotations,
|
||||||
const existing = state.toolCalls[index];
|
citations: event.citations,
|
||||||
if (delta.id) existing.id = delta.id;
|
} satisfies OpenAiChatChoiceDelta];
|
||||||
if (delta.name) existing.name = delta.name;
|
|
||||||
if (delta.argumentsDelta) existing.arguments += delta.argumentsDelta;
|
for (const choiceEvent of choiceEvents) {
|
||||||
}
|
const choice = getChoiceAggregate(state, choiceEvent.index);
|
||||||
}
|
applyChoiceDelta(choice, choiceEvent);
|
||||||
if (Array.isArray(event.annotations)) {
|
|
||||||
for (const annotation of event.annotations) {
|
|
||||||
pushUniqueAnnotation(state.annotations, state.annotationUrls, annotation);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(event.citations)) {
|
if (Array.isArray(event.citations)) {
|
||||||
for (const citation of event.citations) {
|
for (const citation of event.citations) {
|
||||||
if (typeof citation === 'string' && citation.trim()) {
|
if (typeof citation === 'string' && citation.trim()) state.citations.add(citation.trim());
|
||||||
state.citations.add(citation.trim());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const choice of state.choices.values()) {
|
||||||
|
for (const citation of choice.citations) state.citations.add(citation);
|
||||||
}
|
}
|
||||||
state.usageDetails = mergeUsageDetails(state.usageDetails, event.usageDetails);
|
|
||||||
|
state.usageDetails = mergeChatUsageDetails(state.usageDetails, event.usageDetails);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,25 +144,36 @@ export function finalizeOpenAiChatAggregate(
|
|||||||
state: OpenAiChatAggregateState,
|
state: OpenAiChatAggregateState,
|
||||||
normalized: OpenAiChatNormalizedFinalResponse,
|
normalized: OpenAiChatNormalizedFinalResponse,
|
||||||
): OpenAiChatNormalizedFinalResponse {
|
): OpenAiChatNormalizedFinalResponse {
|
||||||
const mergedToolCalls = state.toolCalls.filter((item) => item.id || item.name || item.arguments);
|
if (state.choices.size === 0 && state.citations.size === 0 && !state.usageDetails) {
|
||||||
if (
|
|
||||||
state.content.length === 0
|
|
||||||
&& state.reasoning.length === 0
|
|
||||||
&& mergedToolCalls.length === 0
|
|
||||||
&& state.annotations.length === 0
|
|
||||||
&& state.citations.size === 0
|
|
||||||
&& !state.usageDetails
|
|
||||||
) {
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finalizedChoices: OpenAiChatChoice[] = Array.from(state.choices.values())
|
||||||
|
.sort((left, right) => left.index - right.index)
|
||||||
|
.map((choice) => ({
|
||||||
|
index: choice.index,
|
||||||
|
...(choice.role ? { role: choice.role } : {}),
|
||||||
|
content: choice.content.join(''),
|
||||||
|
reasoningContent: choice.reasoning.join(''),
|
||||||
|
toolCalls: choice.toolCalls.filter((item) => item.id || item.name || item.arguments),
|
||||||
|
finishReason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||||
|
...(choice.annotations.length > 0 ? { annotations: choice.annotations } : {}),
|
||||||
|
...(choice.citations.size > 0 ? { citations: Array.from(choice.citations).sort() } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const primaryChoice = finalizedChoices[0];
|
||||||
|
const normalizedChoices = Array.isArray(normalized.choices) ? normalized.choices : [];
|
||||||
|
const mergedChoices = finalizedChoices.length > 0 ? finalizedChoices : normalizedChoices;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
content: state.content.join('') || normalized.content,
|
content: primaryChoice?.content || normalized.content,
|
||||||
reasoningContent: state.reasoning.join('') || normalized.reasoningContent,
|
reasoningContent: primaryChoice?.reasoningContent || normalized.reasoningContent,
|
||||||
finishReason: state.finishReason ?? normalized.finishReason,
|
finishReason: primaryChoice?.finishReason || normalized.finishReason,
|
||||||
toolCalls: mergedToolCalls.length > 0 ? mergedToolCalls : normalized.toolCalls,
|
toolCalls: primaryChoice?.toolCalls?.length ? primaryChoice.toolCalls : normalized.toolCalls,
|
||||||
annotations: state.annotations.length > 0 ? state.annotations : normalized.annotations,
|
annotations: primaryChoice?.annotations?.length ? primaryChoice.annotations : normalized.annotations,
|
||||||
citations: state.citations.size > 0 ? Array.from(state.citations).sort() : normalized.citations,
|
citations: state.citations.size > 0 ? Array.from(state.citations).sort() : normalized.citations,
|
||||||
usageDetails: mergeUsageDetails(normalized.usageDetails, state.usageDetails),
|
usageDetails: mergeChatUsageDetails(normalized.usageDetails, state.usageDetails),
|
||||||
|
...(mergedChoices.length > 0 ? { choices: mergedChoices } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
|
OpenAiChatChoice,
|
||||||
|
OpenAiChatChoiceDelta,
|
||||||
OpenAiChatNormalizedFinalResponse,
|
OpenAiChatNormalizedFinalResponse,
|
||||||
OpenAiChatNormalizedStreamEvent,
|
OpenAiChatNormalizedStreamEvent,
|
||||||
OpenAiChatRequestMetadata,
|
OpenAiChatRequestMetadata,
|
||||||
|
OpenAiChatToolCall,
|
||||||
OpenAiChatUsageDetails,
|
OpenAiChatUsageDetails,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
|
|
||||||
@@ -31,11 +34,20 @@ function toStringArray(value: unknown): string[] {
|
|||||||
.filter((item) => item.length > 0);
|
.filter((item) => item.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value: unknown): number | undefined {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isFinite(numeric)) return numeric;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function toNumericRecord(value: unknown): Record<string, number> | undefined {
|
function toNumericRecord(value: unknown): Record<string, number> | undefined {
|
||||||
if (!isRecord(value)) return undefined;
|
if (!isRecord(value)) return undefined;
|
||||||
const entries = Object.entries(value)
|
const entries = Object.entries(value)
|
||||||
.map(([key, raw]) => {
|
.map(([key, raw]) => {
|
||||||
const numeric = typeof raw === 'number' ? raw : Number(raw);
|
const numeric = toFiniteNumber(raw);
|
||||||
if (!Number.isFinite(numeric)) return null;
|
if (!Number.isFinite(numeric)) return null;
|
||||||
return [key, numeric] as const;
|
return [key, numeric] as const;
|
||||||
})
|
})
|
||||||
@@ -64,6 +76,112 @@ function annotationUrl(annotation: Record<string, unknown>): string {
|
|||||||
return (direct || nested).trim();
|
return (direct || nested).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractTextPart(value: unknown): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (!isRecord(value)) return '';
|
||||||
|
|
||||||
|
if (typeof value.text === 'string') return value.text;
|
||||||
|
if (typeof value.output_text === 'string') return value.output_text;
|
||||||
|
if (typeof value.content === 'string') return value.content;
|
||||||
|
if (typeof value.reasoning_content === 'string') return value.reasoning_content;
|
||||||
|
if (typeof value.reasoning === 'string') return value.reasoning;
|
||||||
|
|
||||||
|
if (Array.isArray(value.content)) {
|
||||||
|
return value.content.map((item) => extractTextPart(item)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value.parts)) {
|
||||||
|
return value.parts.map((item) => extractTextPart(item)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } {
|
||||||
|
if (typeof value === 'string') return { content: value, reasoning: '' };
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const contentParts: string[] = [];
|
||||||
|
const reasoningParts: string[] = [];
|
||||||
|
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) {
|
||||||
|
if (typeof item === 'string') contentParts.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof item.type === 'string' ? item.type : '';
|
||||||
|
if ((type === 'reasoning' || type === 'thinking') && typeof item.text === 'string') {
|
||||||
|
reasoningParts.push(item.text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((type === 'reasoning' || type === 'thinking') && typeof item.reasoning === 'string') {
|
||||||
|
reasoningParts.push(item.reasoning);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = extractTextPart(item);
|
||||||
|
if (text) contentParts.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: contentParts.join(''),
|
||||||
|
reasoning: reasoningParts.join(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(value)) return { content: '', reasoning: '' };
|
||||||
|
return {
|
||||||
|
content: extractTextPart(value.content ?? value),
|
||||||
|
reasoning: typeof value.reasoning_content === 'string'
|
||||||
|
? value.reasoning_content
|
||||||
|
: typeof value.reasoning === 'string'
|
||||||
|
? value.reasoning
|
||||||
|
: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectToolCalls(value: unknown): OpenAiChatToolCall[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const toolCalls: OpenAiChatToolCall[] = [];
|
||||||
|
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
const functionPart = isRecord(item.function) ? item.function : null;
|
||||||
|
if (!functionPart) continue;
|
||||||
|
const name = typeof functionPart.name === 'string' ? functionPart.name : '';
|
||||||
|
const id = typeof item.id === 'string' ? item.id : '';
|
||||||
|
const args = typeof functionPart.arguments === 'string' ? functionPart.arguments : '';
|
||||||
|
if (!id && !name && !args) continue;
|
||||||
|
toolCalls.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectToolCallDeltas(value: unknown): OpenAiChatChoiceDelta['toolCallDeltas'] {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const deltas = value
|
||||||
|
.map((item, itemIndex) => {
|
||||||
|
if (!isRecord(item)) return null;
|
||||||
|
const functionPart = isRecord(item.function) ? item.function : {};
|
||||||
|
const index = toFiniteNumber(item.index);
|
||||||
|
const delta = {
|
||||||
|
index: index !== undefined ? Math.max(0, Math.trunc(index)) : itemIndex,
|
||||||
|
id: typeof item.id === 'string' && item.id.trim() ? item.id : undefined,
|
||||||
|
name: typeof functionPart.name === 'string' && functionPart.name.trim() ? functionPart.name : undefined,
|
||||||
|
argumentsDelta: typeof functionPart.arguments === 'string' ? functionPart.arguments : undefined,
|
||||||
|
};
|
||||||
|
if (!delta.id && !delta.name && !delta.argumentsDelta) return null;
|
||||||
|
return delta;
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => !!item);
|
||||||
|
|
||||||
|
return deltas.length > 0 ? deltas : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function dedupeAnnotations(input: unknown): Array<Record<string, unknown>> {
|
export function dedupeAnnotations(input: unknown): Array<Record<string, unknown>> {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -98,6 +216,104 @@ export function dedupeCitations(input: unknown): string[] {
|
|||||||
return Array.from(citations);
|
return Array.from(citations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractChoiceAnnotations(choice: unknown): Array<Record<string, unknown>> {
|
||||||
|
if (!isRecord(choice)) return [];
|
||||||
|
const choiceMessage = asRecord(choice.message);
|
||||||
|
const choiceDelta = asRecord(choice.delta);
|
||||||
|
return dedupeAnnotations([
|
||||||
|
...(Array.isArray(choice.annotations) ? choice.annotations : []),
|
||||||
|
...(Array.isArray(choiceMessage?.annotations) ? choiceMessage.annotations : []),
|
||||||
|
...(Array.isArray(choiceDelta?.annotations) ? choiceDelta.annotations : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractChoiceCitations(
|
||||||
|
choice: unknown,
|
||||||
|
sharedCitations: Iterable<string> = [],
|
||||||
|
): string[] {
|
||||||
|
const citations = new Set<string>(sharedCitations);
|
||||||
|
for (const annotation of extractChoiceAnnotations(choice)) {
|
||||||
|
const url = annotationUrl(annotation);
|
||||||
|
if (url) citations.add(url);
|
||||||
|
}
|
||||||
|
if (isRecord(choice)) {
|
||||||
|
for (const citation of dedupeCitations(choice.citations)) citations.add(citation);
|
||||||
|
}
|
||||||
|
return Array.from(citations);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractChatChoices(payload: unknown): OpenAiChatChoice[] {
|
||||||
|
const record = asRecord(payload);
|
||||||
|
if (!record || !Array.isArray(record.choices)) return [];
|
||||||
|
|
||||||
|
const sharedCitations = dedupeCitations(record.citations);
|
||||||
|
const choices: OpenAiChatChoice[] = [];
|
||||||
|
|
||||||
|
for (const [index, choice] of record.choices.entries()) {
|
||||||
|
const choiceRecord = asRecord(choice);
|
||||||
|
if (!choiceRecord) continue;
|
||||||
|
const message = asRecord(choiceRecord.message) ?? asRecord(choiceRecord.delta) ?? {};
|
||||||
|
const parsed = extractTextAndReasoning(message.content ?? message);
|
||||||
|
const toolCalls = collectToolCalls(message.tool_calls);
|
||||||
|
const annotations = extractChoiceAnnotations(choiceRecord);
|
||||||
|
const citations = extractChoiceCitations(choiceRecord, sharedCitations);
|
||||||
|
const finishReason = typeof choiceRecord.finish_reason === 'string' && choiceRecord.finish_reason.length > 0
|
||||||
|
? choiceRecord.finish_reason
|
||||||
|
: (toolCalls.length > 0 ? 'tool_calls' : 'stop');
|
||||||
|
|
||||||
|
choices.push({
|
||||||
|
index: toFiniteNumber(choiceRecord.index) !== undefined ? Math.max(0, Math.trunc(toFiniteNumber(choiceRecord.index)!)) : index,
|
||||||
|
...(message.role === 'assistant' ? { role: 'assistant' as const } : {}),
|
||||||
|
content: parsed.content || (toolCalls.length > 0 ? '' : ''),
|
||||||
|
reasoningContent: typeof message.reasoning_content === 'string'
|
||||||
|
? message.reasoning_content
|
||||||
|
: parsed.reasoning,
|
||||||
|
toolCalls,
|
||||||
|
finishReason,
|
||||||
|
...(annotations.length > 0 ? { annotations } : {}),
|
||||||
|
...(citations.length > 0 ? { citations } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return choices.sort((left, right) => left.index - right.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractChatChoiceEvents(payload: unknown): OpenAiChatChoiceDelta[] {
|
||||||
|
const record = asRecord(payload);
|
||||||
|
if (!record || !Array.isArray(record.choices)) return [];
|
||||||
|
|
||||||
|
const sharedCitations = dedupeCitations(record.citations);
|
||||||
|
const choiceEvents: OpenAiChatChoiceDelta[] = [];
|
||||||
|
|
||||||
|
for (const [index, choice] of record.choices.entries()) {
|
||||||
|
const choiceRecord = asRecord(choice);
|
||||||
|
if (!choiceRecord) continue;
|
||||||
|
const delta = asRecord(choiceRecord.delta) ?? {};
|
||||||
|
const parsed = extractTextAndReasoning(delta.content ?? delta);
|
||||||
|
const toolCallDeltas = collectToolCallDeltas(delta.tool_calls);
|
||||||
|
const annotations = extractChoiceAnnotations(choiceRecord);
|
||||||
|
const citations = extractChoiceCitations(choiceRecord, sharedCitations);
|
||||||
|
const choiceIndex = toFiniteNumber(choiceRecord.index) !== undefined
|
||||||
|
? Math.max(0, Math.trunc(toFiniteNumber(choiceRecord.index)!))
|
||||||
|
: index;
|
||||||
|
|
||||||
|
choiceEvents.push({
|
||||||
|
index: choiceIndex,
|
||||||
|
...(delta.role === 'assistant' ? { role: 'assistant' as const } : {}),
|
||||||
|
...(parsed.content ? { contentDelta: parsed.content } : {}),
|
||||||
|
...((typeof delta.reasoning_content === 'string' ? delta.reasoning_content : parsed.reasoning)
|
||||||
|
? { reasoningDelta: (typeof delta.reasoning_content === 'string' ? delta.reasoning_content : parsed.reasoning) }
|
||||||
|
: {}),
|
||||||
|
...(toolCallDeltas ? { toolCallDeltas } : {}),
|
||||||
|
finishReason: typeof choiceRecord.finish_reason === 'string' ? choiceRecord.finish_reason : null,
|
||||||
|
...(annotations.length > 0 ? { annotations } : {}),
|
||||||
|
...(citations.length > 0 ? { citations } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return choiceEvents;
|
||||||
|
}
|
||||||
|
|
||||||
function extractAnnotationsFromPayload(payload: Record<string, unknown>): Array<Record<string, unknown>> {
|
function extractAnnotationsFromPayload(payload: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||||
const collected: Array<Record<string, unknown>> = [];
|
const collected: Array<Record<string, unknown>> = [];
|
||||||
const append = (value: unknown) => {
|
const append = (value: unknown) => {
|
||||||
@@ -216,18 +432,25 @@ export function extractChatRequestMetadata(body: unknown): OpenAiChatRequestMeta
|
|||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
|
|
||||||
const streamOptions = asRecord(raw.stream_options) ?? asRecord(raw.streamOptions);
|
const streamOptions = asRecord(raw.stream_options) ?? asRecord(raw.streamOptions);
|
||||||
|
const modalities = Array.isArray(raw.modalities)
|
||||||
|
? raw.modalities.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||||
|
: undefined;
|
||||||
|
const audio = asRecord(raw.audio) as OpenAiChatRequestMetadata['audio'] | null;
|
||||||
|
const reasoningBudget = toFiniteNumber(raw.reasoning_budget);
|
||||||
|
const topLogprobs = toFiniteNumber(raw.top_logprobs);
|
||||||
|
const logitBias = toNumericRecord(raw.logit_bias);
|
||||||
const metadata: OpenAiChatRequestMetadata = {
|
const metadata: OpenAiChatRequestMetadata = {
|
||||||
...(raw.modalities !== undefined ? { modalities: raw.modalities } : {}),
|
...(modalities && modalities.length > 0 ? { modalities } : {}),
|
||||||
...(raw.audio !== undefined ? { audio: raw.audio } : {}),
|
...(audio ? { audio } : {}),
|
||||||
...(raw.reasoning_effort !== undefined ? { reasoningEffort: raw.reasoning_effort } : {}),
|
...(typeof raw.reasoning_effort === 'string' ? { reasoningEffort: raw.reasoning_effort } : {}),
|
||||||
...(raw.reasoning_budget !== undefined ? { reasoningBudget: raw.reasoning_budget } : {}),
|
...(reasoningBudget !== undefined ? { reasoningBudget } : {}),
|
||||||
...(raw.reasoning_summary !== undefined ? { reasoningSummary: raw.reasoning_summary } : {}),
|
...(typeof raw.reasoning_summary === 'string' ? { reasoningSummary: raw.reasoning_summary } : {}),
|
||||||
...(raw.service_tier !== undefined ? { serviceTier: raw.service_tier } : {}),
|
...(typeof raw.service_tier === 'string' ? { serviceTier: raw.service_tier } : {}),
|
||||||
...(raw.top_logprobs !== undefined ? { topLogprobs: raw.top_logprobs } : {}),
|
...(topLogprobs !== undefined ? { topLogprobs } : {}),
|
||||||
...(raw.logit_bias !== undefined ? { logitBias: raw.logit_bias } : {}),
|
...(logitBias ? { logitBias } : {}),
|
||||||
...(raw.prompt_cache_key !== undefined ? { promptCacheKey: raw.prompt_cache_key } : {}),
|
...(typeof raw.prompt_cache_key === 'string' ? { promptCacheKey: raw.prompt_cache_key } : {}),
|
||||||
...(raw.safety_identifier !== undefined ? { safetyIdentifier: raw.safety_identifier } : {}),
|
...(typeof raw.safety_identifier === 'string' ? { safetyIdentifier: raw.safety_identifier } : {}),
|
||||||
...(raw.verbosity !== undefined ? { verbosity: raw.verbosity } : {}),
|
...(typeof raw.verbosity === 'string' ? { verbosity: raw.verbosity } : {}),
|
||||||
...(raw.response_format !== undefined ? { responseFormat: raw.response_format } : {}),
|
...(raw.response_format !== undefined ? { responseFormat: raw.response_format } : {}),
|
||||||
...(streamOptions && streamOptions.include_usage !== undefined
|
...(streamOptions && streamOptions.include_usage !== undefined
|
||||||
? { streamOptionsIncludeUsage: Boolean(streamOptions.include_usage) }
|
? { streamOptionsIncludeUsage: Boolean(streamOptions.include_usage) }
|
||||||
|
|||||||
@@ -61,6 +61,48 @@ describe('openAiChatTransformer.inbound', () => {
|
|||||||
streamOptionsIncludeUsage: true,
|
streamOptionsIncludeUsage: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes typed inbound metadata without mutating upstream body', () => {
|
||||||
|
const result = openAiChatTransformer.transformRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
modalities: ['text', 42, 'audio', '', null],
|
||||||
|
audio: { voice: 'alloy', format: 'wav' },
|
||||||
|
reasoning_effort: 'medium',
|
||||||
|
reasoning_budget: '2048',
|
||||||
|
reasoning_summary: 'concise',
|
||||||
|
service_tier: 'priority',
|
||||||
|
top_logprobs: '5',
|
||||||
|
logit_bias: { '42': '7', invalid: 'oops' },
|
||||||
|
prompt_cache_key: 'cache-key',
|
||||||
|
safety_identifier: 'safety-id',
|
||||||
|
verbosity: 'high',
|
||||||
|
stream_options: { include_usage: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.value?.upstreamBody).toMatchObject({
|
||||||
|
modalities: ['text', 42, 'audio', '', null],
|
||||||
|
reasoning_budget: '2048',
|
||||||
|
top_logprobs: '5',
|
||||||
|
logit_bias: { '42': '7', invalid: 'oops' },
|
||||||
|
stream_options: { include_usage: 1 },
|
||||||
|
});
|
||||||
|
expect((result.value as any)?.requestMetadata).toEqual({
|
||||||
|
modalities: ['text', 'audio'],
|
||||||
|
audio: { voice: 'alloy', format: 'wav' },
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
reasoningBudget: 2048,
|
||||||
|
reasoningSummary: 'concise',
|
||||||
|
serviceTier: 'priority',
|
||||||
|
topLogprobs: 5,
|
||||||
|
logitBias: { '42': 7 },
|
||||||
|
promptCacheKey: 'cache-key',
|
||||||
|
safetyIdentifier: 'safety-id',
|
||||||
|
verbosity: 'high',
|
||||||
|
streamOptionsIncludeUsage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('openAiChatTransformer.outbound', () => {
|
describe('openAiChatTransformer.outbound', () => {
|
||||||
@@ -111,6 +153,99 @@ describe('openAiChatTransformer.outbound', () => {
|
|||||||
completion_tokens_details: { reasoning_tokens: 2 },
|
completion_tokens_details: { reasoning_tokens: 2 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => {
|
||||||
|
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||||
|
id: 'chatcmpl-multi',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 123,
|
||||||
|
citations: ['https://shared.example', 'https://shared.example'],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 21,
|
||||||
|
completion_tokens: 9,
|
||||||
|
total_tokens: 30,
|
||||||
|
prompt_tokens_details: { cached_tokens: 4, audio_tokens: 1 },
|
||||||
|
completion_tokens_details: { reasoning_tokens: 3, audio_tokens: 2 },
|
||||||
|
},
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'choice-0',
|
||||||
|
reasoning_content: 'reason-0',
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finish_reason: 'tool_calls',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
reasoning_content: 'reason-1',
|
||||||
|
tool_calls: [{
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'search', arguments: '{"q":"cat"}' },
|
||||||
|
}],
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, 'gpt-5');
|
||||||
|
|
||||||
|
const payload = openAiChatTransformer.serializeFinalResponse(normalized, {
|
||||||
|
promptTokens: 21,
|
||||||
|
completionTokens: 9,
|
||||||
|
totalTokens: 30,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
expect(payload.choices).toHaveLength(2);
|
||||||
|
expect(payload.choices[0]).toMatchObject({
|
||||||
|
index: 0,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'choice-0',
|
||||||
|
reasoning_content: 'reason-0',
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(payload.choices[1]).toMatchObject({
|
||||||
|
index: 1,
|
||||||
|
finish_reason: 'tool_calls',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
reasoning_content: 'reason-1',
|
||||||
|
tool_calls: [{
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'search', arguments: '{"q":"cat"}' },
|
||||||
|
}],
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(payload.citations).toEqual([
|
||||||
|
'https://shared.example',
|
||||||
|
'https://a.example',
|
||||||
|
'https://b.example',
|
||||||
|
]);
|
||||||
|
expect(payload.usage).toMatchObject({
|
||||||
|
prompt_tokens_details: { cached_tokens: 4, audio_tokens: 1 },
|
||||||
|
completion_tokens_details: { reasoning_tokens: 3, audio_tokens: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('openAiChatTransformer.stream', () => {
|
describe('openAiChatTransformer.stream', () => {
|
||||||
@@ -157,6 +292,89 @@ describe('openAiChatTransformer.stream', () => {
|
|||||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes multi-choice stream chunks without collapsing choice-specific deltas', () => {
|
||||||
|
const context = openAiChatTransformer.createStreamContext('gpt-5');
|
||||||
|
const event = openAiChatTransformer.transformStreamEvent({
|
||||||
|
id: 'chatcmpl-stream-multi',
|
||||||
|
model: 'gpt-5',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finish_reason: null,
|
||||||
|
delta: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'choice-0',
|
||||||
|
reasoning_content: 'reason-0',
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finish_reason: null,
|
||||||
|
delta: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'choice-1',
|
||||||
|
tool_calls: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'search', arguments: '{"q":"dog"}' },
|
||||||
|
}],
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
citations: ['https://shared.example'],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 11,
|
||||||
|
completion_tokens: 7,
|
||||||
|
total_tokens: 18,
|
||||||
|
prompt_tokens_details: { cached_tokens: 3 },
|
||||||
|
},
|
||||||
|
}, context, 'gpt-5');
|
||||||
|
|
||||||
|
const payloads = parseSsePayloads(
|
||||||
|
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payloads[0].choices).toHaveLength(2);
|
||||||
|
expect((payloads[0] as any).choices[0]).toMatchObject({
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'choice-0',
|
||||||
|
reasoning_content: 'reason-0',
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((payloads[0] as any).choices[1]).toMatchObject({
|
||||||
|
index: 1,
|
||||||
|
delta: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'choice-1',
|
||||||
|
tool_calls: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
function: { name: 'search', arguments: '{"q":"dog"}' },
|
||||||
|
}],
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((payloads[0] as any).citations).toEqual([
|
||||||
|
'https://shared.example',
|
||||||
|
'https://a.example',
|
||||||
|
'https://b.example',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('openAiChatTransformer.aggregator', () => {
|
describe('openAiChatTransformer.aggregator', () => {
|
||||||
@@ -231,4 +449,85 @@ describe('openAiChatTransformer.aggregator', () => {
|
|||||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aggregates stream events by choice index and preserves per-choice annotations, citations, tools, reasoning, and usage details', () => {
|
||||||
|
const state = openAiChatTransformer.aggregator.createState();
|
||||||
|
|
||||||
|
openAiChatTransformer.aggregator.applyEvent(state, {
|
||||||
|
choiceIndex: 0,
|
||||||
|
role: 'assistant',
|
||||||
|
contentDelta: 'hello',
|
||||||
|
reasoningDelta: 'why-0',
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||||
|
],
|
||||||
|
citations: ['https://shared.example'],
|
||||||
|
usageDetails: {
|
||||||
|
prompt_tokens_details: { cached_tokens: 2 },
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
openAiChatTransformer.aggregator.applyEvent(state, {
|
||||||
|
choiceIndex: 1,
|
||||||
|
role: 'assistant',
|
||||||
|
contentDelta: 'tool-choice',
|
||||||
|
reasoningDelta: 'why-1',
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'search',
|
||||||
|
argumentsDelta: '{"q":"bird"}',
|
||||||
|
}],
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||||
|
],
|
||||||
|
citations: ['https://other.example'],
|
||||||
|
usageDetails: {
|
||||||
|
completion_tokens_details: { reasoning_tokens: 4 },
|
||||||
|
},
|
||||||
|
finishReason: 'tool_calls',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const normalized = openAiChatTransformer.aggregator.finalize(state, {
|
||||||
|
id: 'chatcmpl-multi',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 123,
|
||||||
|
content: '',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'stop',
|
||||||
|
toolCalls: [],
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
expect(normalized.choices).toHaveLength(2);
|
||||||
|
expect(normalized.choices[0]).toMatchObject({
|
||||||
|
index: 0,
|
||||||
|
content: 'hello',
|
||||||
|
reasoningContent: 'why-0',
|
||||||
|
finishReason: 'stop',
|
||||||
|
citations: ['https://shared.example'],
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(normalized.choices[1]).toMatchObject({
|
||||||
|
index: 1,
|
||||||
|
content: 'tool-choice',
|
||||||
|
reasoningContent: 'why-1',
|
||||||
|
finishReason: 'tool_calls',
|
||||||
|
toolCalls: [{
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'search',
|
||||||
|
arguments: '{"q":"bird"}',
|
||||||
|
}],
|
||||||
|
citations: ['https://other.example'],
|
||||||
|
annotations: [
|
||||||
|
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(normalized.citations).toEqual(['https://other.example', 'https://shared.example']);
|
||||||
|
expect(normalized.usageDetails).toEqual({
|
||||||
|
prompt_tokens_details: { cached_tokens: 2 },
|
||||||
|
completion_tokens_details: { reasoning_tokens: 4 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext } from '../../shared/normalized.js';
|
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
|
||||||
import { openAiChatInbound } from './inbound.js';
|
import { openAiChatInbound } from './inbound.js';
|
||||||
import { openAiChatOutbound } from './outbound.js';
|
import { openAiChatOutbound } from './outbound.js';
|
||||||
import { openAiChatStream } from './stream.js';
|
import { openAiChatStream } from './stream.js';
|
||||||
import { openAiChatUsage } from './usage.js';
|
import { openAiChatUsage } from './usage.js';
|
||||||
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
||||||
|
import type { OpenAiChatParsedRequest as OpenAiChatParsedRequestModel } from './model.js';
|
||||||
|
|
||||||
export const openAiChatTransformer = {
|
export const openAiChatTransformer = {
|
||||||
protocol: 'openai/chat' as const,
|
protocol: 'openai/chat' as const,
|
||||||
@@ -47,6 +48,20 @@ export const openAiChatTransformer = {
|
|||||||
) {
|
) {
|
||||||
return openAiChatOutbound.serializeFinal(normalized, usage);
|
return openAiChatOutbound.serializeFinal(normalized, usage);
|
||||||
},
|
},
|
||||||
|
serializeUpstreamFinalAsStream(
|
||||||
|
payload: unknown,
|
||||||
|
modelName: string,
|
||||||
|
fallbackText: string,
|
||||||
|
streamContext: StreamTransformContext,
|
||||||
|
) {
|
||||||
|
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||||
|
streamContext.id = normalizedFinal.id;
|
||||||
|
streamContext.model = normalizedFinal.model;
|
||||||
|
streamContext.created = normalizedFinal.created;
|
||||||
|
return openAiChatOutbound
|
||||||
|
.buildSyntheticChunks(normalizedFinal)
|
||||||
|
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`);
|
||||||
|
},
|
||||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||||
return openAiChatOutbound.buildSyntheticChunks(normalized);
|
return openAiChatOutbound.buildSyntheticChunks(normalized);
|
||||||
},
|
},
|
||||||
@@ -56,4 +71,4 @@ export const openAiChatTransformer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type OpenAiChatTransformer = typeof openAiChatTransformer;
|
export type OpenAiChatTransformer = typeof openAiChatTransformer;
|
||||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
|
export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel;
|
||||||
|
|||||||
@@ -4,18 +4,24 @@ import type {
|
|||||||
ParsedDownstreamChatRequest,
|
ParsedDownstreamChatRequest,
|
||||||
} from '../../shared/normalized.js';
|
} from '../../shared/normalized.js';
|
||||||
|
|
||||||
|
export type OpenAiChatAudioRequest = {
|
||||||
|
format?: string;
|
||||||
|
voice?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type OpenAiChatRequestMetadata = {
|
export type OpenAiChatRequestMetadata = {
|
||||||
modalities?: unknown;
|
modalities?: string[];
|
||||||
audio?: unknown;
|
audio?: OpenAiChatAudioRequest;
|
||||||
reasoningEffort?: unknown;
|
reasoningEffort?: string;
|
||||||
reasoningBudget?: unknown;
|
reasoningBudget?: number;
|
||||||
reasoningSummary?: unknown;
|
reasoningSummary?: string;
|
||||||
serviceTier?: unknown;
|
serviceTier?: string;
|
||||||
topLogprobs?: unknown;
|
topLogprobs?: number;
|
||||||
logitBias?: unknown;
|
logitBias?: Record<string, number>;
|
||||||
promptCacheKey?: unknown;
|
promptCacheKey?: string;
|
||||||
safetyIdentifier?: unknown;
|
safetyIdentifier?: string;
|
||||||
verbosity?: unknown;
|
verbosity?: string;
|
||||||
responseFormat?: unknown;
|
responseFormat?: unknown;
|
||||||
streamOptionsIncludeUsage?: boolean | null;
|
streamOptionsIncludeUsage?: boolean | null;
|
||||||
};
|
};
|
||||||
@@ -29,14 +35,50 @@ export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & {
|
|||||||
requestMetadata?: OpenAiChatRequestMetadata;
|
requestMetadata?: OpenAiChatRequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OpenAiChatToolCall = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenAiChatChoice = {
|
||||||
|
index: number;
|
||||||
|
role?: 'assistant';
|
||||||
|
content: string;
|
||||||
|
reasoningContent: string;
|
||||||
|
toolCalls: OpenAiChatToolCall[];
|
||||||
|
finishReason: string;
|
||||||
|
annotations?: Array<Record<string, unknown>>;
|
||||||
|
citations?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type OpenAiChatNormalizedFinalResponse = NormalizedFinalResponse & {
|
export type OpenAiChatNormalizedFinalResponse = NormalizedFinalResponse & {
|
||||||
|
choices?: OpenAiChatChoice[];
|
||||||
annotations?: Array<Record<string, unknown>>;
|
annotations?: Array<Record<string, unknown>>;
|
||||||
citations?: string[];
|
citations?: string[];
|
||||||
usageDetails?: OpenAiChatUsageDetails;
|
usageDetails?: OpenAiChatUsageDetails;
|
||||||
usagePayload?: Record<string, unknown>;
|
usagePayload?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OpenAiChatChoiceDelta = {
|
||||||
|
index: number;
|
||||||
|
role?: 'assistant';
|
||||||
|
contentDelta?: string;
|
||||||
|
reasoningDelta?: string;
|
||||||
|
toolCallDeltas?: Array<{
|
||||||
|
index: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
argumentsDelta?: string;
|
||||||
|
}>;
|
||||||
|
finishReason?: string | null;
|
||||||
|
annotations?: Array<Record<string, unknown>>;
|
||||||
|
citations?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type OpenAiChatNormalizedStreamEvent = NormalizedStreamEvent & {
|
export type OpenAiChatNormalizedStreamEvent = NormalizedStreamEvent & {
|
||||||
|
choiceIndex?: number;
|
||||||
|
choiceEvents?: OpenAiChatChoiceDelta[];
|
||||||
annotations?: Array<Record<string, unknown>>;
|
annotations?: Array<Record<string, unknown>>;
|
||||||
citations?: string[];
|
citations?: string[];
|
||||||
usageDetails?: OpenAiChatUsageDetails;
|
usageDetails?: OpenAiChatUsageDetails;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
serializeFinalResponse,
|
serializeFinalResponse,
|
||||||
type NormalizedFinalResponse,
|
type NormalizedFinalResponse,
|
||||||
} from '../../shared/normalized.js';
|
} from '../../shared/normalized.js';
|
||||||
import { extractChatResponseExtras } from './helpers.js';
|
import { extractChatChoices, extractChatResponseExtras } from './helpers.js';
|
||||||
import type { OpenAiChatNormalizedFinalResponse } from './model.js';
|
import type { OpenAiChatNormalizedFinalResponse } from './model.js';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -39,8 +39,13 @@ function mergeUsagePayload(
|
|||||||
|
|
||||||
export const openAiChatOutbound = {
|
export const openAiChatOutbound = {
|
||||||
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): OpenAiChatNormalizedFinalResponse {
|
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): OpenAiChatNormalizedFinalResponse {
|
||||||
|
const choices = extractChatChoices(payload);
|
||||||
|
const primaryChoice = choices[0];
|
||||||
return {
|
return {
|
||||||
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
||||||
|
...(choices.length > 0 ? { choices } : {}),
|
||||||
|
...(primaryChoice?.annotations?.length ? { annotations: primaryChoice.annotations } : {}),
|
||||||
|
...(primaryChoice?.citations?.length ? { citations: primaryChoice.citations } : {}),
|
||||||
...extractChatResponseExtras(payload),
|
...extractChatResponseExtras(payload),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -52,16 +57,52 @@ export const openAiChatOutbound = {
|
|||||||
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
|
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||||
) as Record<string, unknown>;
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||||
payload.citations = chatNormalized.citations;
|
? 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
return {
|
||||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
index: choice.index,
|
||||||
const firstChoice = choices[0];
|
message,
|
||||||
if (isRecord(firstChoice) && isRecord(firstChoice.message)) {
|
finish_reason: choice.finishReason || (choice.toolCalls?.length ? 'tool_calls' : 'stop'),
|
||||||
firstChoice.message.annotations = chatNormalized.annotations;
|
};
|
||||||
}
|
}).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);
|
const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized);
|
||||||
@@ -75,8 +116,57 @@ export const openAiChatOutbound = {
|
|||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||||
const chunks = buildSyntheticOpenAiChunks(normalized);
|
|
||||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
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 (chunks.length <= 0) return chunks;
|
||||||
|
|
||||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||||
@@ -86,7 +176,7 @@ export const openAiChatOutbound = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
if (!choices && Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||||
const firstChunk = isRecord(chunks[0]) ? chunks[0] : null;
|
const firstChunk = isRecord(chunks[0]) ? chunks[0] : null;
|
||||||
const firstChoice = firstChunk && Array.isArray(firstChunk.choices) ? firstChunk.choices[0] : null;
|
const firstChoice = firstChunk && Array.isArray(firstChunk.choices) ? firstChunk.choices[0] : null;
|
||||||
if (isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
if (isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type ClaudeDownstreamContext,
|
type ClaudeDownstreamContext,
|
||||||
type StreamTransformContext,
|
type StreamTransformContext,
|
||||||
} from '../../shared/normalized.js';
|
} from '../../shared/normalized.js';
|
||||||
import { extractChatResponseExtras } from './helpers.js';
|
import { extractChatChoiceEvents, extractChatResponseExtras } from './helpers.js';
|
||||||
import type { OpenAiChatNormalizedStreamEvent } from './model.js';
|
import type { OpenAiChatNormalizedStreamEvent } from './model.js';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -36,8 +36,23 @@ export const openAiChatStream = {
|
|||||||
return createStreamTransformContext(modelName);
|
return createStreamTransformContext(modelName);
|
||||||
},
|
},
|
||||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
|
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
|
||||||
|
const choiceEvents = extractChatChoiceEvents(payload);
|
||||||
|
const primaryChoice = choiceEvents[0];
|
||||||
return {
|
return {
|
||||||
...normalizeUpstreamStreamEvent(payload, context, modelName),
|
...normalizeUpstreamStreamEvent(payload, context, modelName),
|
||||||
|
...(primaryChoice
|
||||||
|
? {
|
||||||
|
choiceIndex: primaryChoice.index,
|
||||||
|
role: primaryChoice.role,
|
||||||
|
contentDelta: primaryChoice.contentDelta,
|
||||||
|
reasoningDelta: primaryChoice.reasoningDelta,
|
||||||
|
toolCallDeltas: primaryChoice.toolCallDeltas,
|
||||||
|
finishReason: primaryChoice.finishReason,
|
||||||
|
annotations: primaryChoice.annotations,
|
||||||
|
citations: primaryChoice.citations,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(choiceEvents.length > 0 ? { choiceEvents } : {}),
|
||||||
...extractChatResponseExtras(payload),
|
...extractChatResponseExtras(payload),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -46,6 +61,60 @@ export const openAiChatStream = {
|
|||||||
context: StreamTransformContext,
|
context: StreamTransformContext,
|
||||||
downstreamContext?: ClaudeDownstreamContext,
|
downstreamContext?: ClaudeDownstreamContext,
|
||||||
): string[] {
|
): 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(
|
const lines = serializeNormalizedStreamEvent(
|
||||||
'openai',
|
'openai',
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -1,150 +1,45 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createStreamTransformContext } from '../../shared/normalized.js';
|
||||||
import {
|
import {
|
||||||
createOpenAiResponsesAggregateState,
|
createOpenAiResponsesAggregateState,
|
||||||
serializeConvertedResponsesEvents,
|
serializeConvertedResponsesEvents,
|
||||||
type OpenAiResponsesAggregateState,
|
|
||||||
completeResponsesStream,
|
|
||||||
} from './aggregator.js';
|
} from './aggregator.js';
|
||||||
import { openAiResponsesStream } from './stream.js';
|
|
||||||
|
|
||||||
function collectSerializedEvents(input: string[]): Array<{ event: string; data: any }> {
|
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
||||||
const events: Array<{ event: string; data: any }> = [];
|
return lines
|
||||||
for (const chunk of input) {
|
.flatMap((line) => line.split('\n\n').filter((block) => block.trim().length > 0))
|
||||||
const blocks = chunk.split('\n\n').filter((item) => item.trim().length > 0);
|
.map((block) => {
|
||||||
for (const block of blocks) {
|
const dataLine = block
|
||||||
const lines = block.split('\n');
|
.split('\n')
|
||||||
const eventLine = lines.find((line) => line.startsWith('event: '));
|
.find((line) => line.startsWith('data: '));
|
||||||
const dataLine = lines.find((line) => line.startsWith('data: '));
|
if (!dataLine) return null;
|
||||||
if (!dataLine) continue;
|
try {
|
||||||
const rawData = dataLine.slice('data: '.length);
|
return JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>;
|
||||||
if (rawData === '[DONE]') {
|
} catch {
|
||||||
events.push({ event: eventLine?.slice('event: '.length) || 'done', data: '[DONE]' });
|
return null;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
events.push({
|
})
|
||||||
event: eventLine?.slice('event: '.length) || '',
|
.filter((item): item is Record<string, unknown> => !!item);
|
||||||
data: JSON.parse(rawData),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return events;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function feedResponsesEvent(
|
describe('serializeConvertedResponsesEvents', () => {
|
||||||
state: OpenAiResponsesAggregateState,
|
it('aggregates reasoning summary events into the completed response payload', () => {
|
||||||
payload: Record<string, unknown>,
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
): Array<{ event: string; data: any }> {
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
const context = openAiResponsesStream.createContext('gpt-5');
|
const usage = {
|
||||||
const normalizedEvent = openAiResponsesStream.normalizeEvent(payload, context, 'gpt-5');
|
promptTokens: 1,
|
||||||
return collectSerializedEvents(
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
serializeConvertedResponsesEvents({
|
serializeConvertedResponsesEvents({
|
||||||
state,
|
state,
|
||||||
streamContext: context,
|
streamContext,
|
||||||
event: normalizedEvent,
|
usage,
|
||||||
usage: {
|
event: {
|
||||||
promptTokens: 0,
|
responsesEventType: 'response.reasoning_summary_part.added',
|
||||||
completionTokens: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('openAiResponsesStream / aggregator', () => {
|
|
||||||
it('keeps reasoning separate from assistant text for normalized events and merges usage details', () => {
|
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
|
||||||
const context = openAiResponsesStream.createContext('gpt-5');
|
|
||||||
|
|
||||||
const contentEvent = {
|
|
||||||
role: 'assistant',
|
|
||||||
contentDelta: 'hello',
|
|
||||||
};
|
|
||||||
const reasoningEvent = {
|
|
||||||
reasoningDelta: 'plan first',
|
|
||||||
responsesPayload: {
|
responsesPayload: {
|
||||||
usage: {
|
|
||||||
input_tokens: 5,
|
|
||||||
output_tokens: 3,
|
|
||||||
total_tokens: 8,
|
|
||||||
output_tokens_details: { reasoning_tokens: 2 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
collectSerializedEvents(
|
|
||||||
serializeConvertedResponsesEvents({
|
|
||||||
state,
|
|
||||||
streamContext: context,
|
|
||||||
event: contentEvent as any,
|
|
||||||
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const reasoningEmitted = collectSerializedEvents(
|
|
||||||
serializeConvertedResponsesEvents({
|
|
||||||
state,
|
|
||||||
streamContext: context,
|
|
||||||
event: reasoningEvent as any,
|
|
||||||
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
|
|
||||||
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_text.delta')).toBe(true);
|
|
||||||
|
|
||||||
const completed = collectSerializedEvents(
|
|
||||||
completeResponsesStream(state, context, { promptTokens: 5, completionTokens: 3, totalTokens: 8 }),
|
|
||||||
).find((item) => item.event === 'response.completed');
|
|
||||||
|
|
||||||
expect(completed?.data.response.output).toEqual([
|
|
||||||
{
|
|
||||||
id: expect.any(String),
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
status: 'completed',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'output_text',
|
|
||||||
text: 'hello',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: expect.any(String),
|
|
||||||
type: 'reasoning',
|
|
||||||
status: 'completed',
|
|
||||||
summary: [
|
|
||||||
{
|
|
||||||
type: 'summary_text',
|
|
||||||
text: 'plan first',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(completed?.data.response.usage).toMatchObject({
|
|
||||||
input_tokens: 5,
|
|
||||||
output_tokens: 3,
|
|
||||||
total_tokens: 8,
|
|
||||||
output_tokens_details: { reasoning_tokens: 2 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks reasoning summary events into completed response output', () => {
|
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
|
||||||
|
|
||||||
const emitted = [
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.output_item.added',
|
|
||||||
output_index: 0,
|
|
||||||
item: {
|
|
||||||
id: 'rs_1',
|
|
||||||
type: 'reasoning',
|
|
||||||
status: 'in_progress',
|
|
||||||
summary: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.reasoning_summary_part.added',
|
type: 'response.reasoning_summary_part.added',
|
||||||
item_id: 'rs_1',
|
item_id: 'rs_1',
|
||||||
output_index: 0,
|
output_index: 0,
|
||||||
@@ -153,184 +48,133 @@ describe('openAiResponsesStream / aggregator', () => {
|
|||||||
type: 'summary_text',
|
type: 'summary_text',
|
||||||
text: '',
|
text: '',
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
...feedResponsesEvent(state, {
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.reasoning_summary_text.delta',
|
||||||
|
responsesPayload: {
|
||||||
type: 'response.reasoning_summary_text.delta',
|
type: 'response.reasoning_summary_text.delta',
|
||||||
item_id: 'rs_1',
|
item_id: 'rs_1',
|
||||||
output_index: 0,
|
output_index: 0,
|
||||||
summary_index: 0,
|
summary_index: 0,
|
||||||
delta: 'Plan',
|
delta: 'Think ',
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.reasoning_summary_text.done',
|
|
||||||
item_id: 'rs_1',
|
|
||||||
output_index: 0,
|
|
||||||
summary_index: 0,
|
|
||||||
text: 'Plan it out',
|
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.reasoning_summary_part.done',
|
|
||||||
item_id: 'rs_1',
|
|
||||||
output_index: 0,
|
|
||||||
summary_index: 0,
|
|
||||||
part: {
|
|
||||||
type: 'summary_text',
|
|
||||||
text: 'Plan it out',
|
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.output_item.done',
|
|
||||||
output_index: 0,
|
|
||||||
item: {
|
|
||||||
id: 'rs_1',
|
|
||||||
type: 'reasoning',
|
|
||||||
status: 'completed',
|
|
||||||
summary: [],
|
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
|
const completedLines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.completed',
|
||||||
|
responsesPayload: {
|
||||||
type: 'response.completed',
|
type: 'response.completed',
|
||||||
response: {
|
response: {
|
||||||
id: 'resp_reasoning',
|
id: 'resp_1',
|
||||||
object: 'response',
|
|
||||||
created_at: 1700000000,
|
|
||||||
status: 'completed',
|
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
output: [],
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 2,
|
||||||
|
total_tokens: 3,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
];
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
expect(emitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
|
const payloads = parseSsePayloads(completedLines);
|
||||||
expect(emitted.some((item) => item.event === 'response.reasoning_summary_text.done')).toBe(true);
|
const completed = payloads.find((item) => item.type === 'response.completed');
|
||||||
|
expect(completed).toBeTruthy();
|
||||||
const completed = emitted.find((item) => item.event === 'response.completed');
|
expect(completed?.response).toMatchObject({
|
||||||
expect(completed?.data.response.output).toEqual([
|
id: 'resp_1',
|
||||||
|
output: [
|
||||||
{
|
{
|
||||||
id: 'rs_1',
|
id: 'rs_1',
|
||||||
type: 'reasoning',
|
type: 'reasoning',
|
||||||
status: 'completed',
|
|
||||||
summary: [
|
summary: [
|
||||||
{
|
{
|
||||||
type: 'summary_text',
|
type: 'summary_text',
|
||||||
text: 'Plan it out',
|
text: 'Think ',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tracks custom tool input and image generation results', () => {
|
it('preserves richer image_generation_call fields while aggregating progress events', () => {
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
const emitted = [
|
serializeConvertedResponsesEvents({
|
||||||
...feedResponsesEvent(state, {
|
state,
|
||||||
type: 'response.output_item.added',
|
streamContext,
|
||||||
output_index: 0,
|
usage,
|
||||||
item: {
|
event: {
|
||||||
id: 'ct_1',
|
responsesEventType: 'response.image_generation_call.partial_image',
|
||||||
type: 'custom_tool_call',
|
responsesPayload: {
|
||||||
status: 'in_progress',
|
|
||||||
call_id: 'ct_1',
|
|
||||||
name: 'browser',
|
|
||||||
input: '',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.custom_tool_call_input.delta',
|
|
||||||
item_id: 'ct_1',
|
|
||||||
output_index: 0,
|
|
||||||
delta: 'open ',
|
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.custom_tool_call_input.done',
|
|
||||||
item_id: 'ct_1',
|
|
||||||
output_index: 0,
|
|
||||||
input: 'open example.com',
|
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.output_item.done',
|
|
||||||
output_index: 0,
|
|
||||||
item: {
|
|
||||||
id: 'ct_1',
|
|
||||||
type: 'custom_tool_call',
|
|
||||||
status: 'completed',
|
|
||||||
call_id: 'ct_1',
|
|
||||||
name: 'browser',
|
|
||||||
input: 'open example.com',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.output_item.added',
|
|
||||||
output_index: 1,
|
|
||||||
item: {
|
|
||||||
id: 'img_1',
|
|
||||||
type: 'image_generation_call',
|
|
||||||
status: 'in_progress',
|
|
||||||
result: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...feedResponsesEvent(state, {
|
|
||||||
type: 'response.image_generation_call.partial_image',
|
type: 'response.image_generation_call.partial_image',
|
||||||
item_id: 'img_1',
|
item_id: 'img_1',
|
||||||
output_index: 1,
|
output_index: 0,
|
||||||
partial_image_index: 0,
|
partial_image_index: 0,
|
||||||
partial_image_b64: 'abc123',
|
partial_image_b64: 'partial',
|
||||||
}),
|
background: 'transparent',
|
||||||
...feedResponsesEvent(state, {
|
output_format: 'png',
|
||||||
|
quality: 'high',
|
||||||
|
size: '1024x1024',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedLines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.image_generation_call.completed',
|
||||||
|
responsesPayload: {
|
||||||
type: 'response.image_generation_call.completed',
|
type: 'response.image_generation_call.completed',
|
||||||
item_id: 'img_1',
|
item_id: 'img_1',
|
||||||
output_index: 1,
|
output_index: 0,
|
||||||
result: 'data:image/png;base64,final',
|
result: 'final-image',
|
||||||
}),
|
background: 'transparent',
|
||||||
...feedResponsesEvent(state, {
|
output_format: 'png',
|
||||||
type: 'response.output_item.done',
|
quality: 'high',
|
||||||
output_index: 1,
|
size: '1024x1024',
|
||||||
item: {
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payloads = parseSsePayloads(completedLines);
|
||||||
|
const imageEvent = payloads.find((item) => item.type === 'response.image_generation_call.completed');
|
||||||
|
expect(imageEvent).toBeTruthy();
|
||||||
|
expect(state.outputItems[0]).toMatchObject({
|
||||||
id: 'img_1',
|
id: 'img_1',
|
||||||
type: 'image_generation_call',
|
type: 'image_generation_call',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
result: 'data:image/png;base64,final',
|
result: 'final-image',
|
||||||
},
|
background: 'transparent',
|
||||||
}),
|
output_format: 'png',
|
||||||
...feedResponsesEvent(state, {
|
quality: 'high',
|
||||||
type: 'response.completed',
|
size: '1024x1024',
|
||||||
response: {
|
|
||||||
id: 'resp_tools',
|
|
||||||
object: 'response',
|
|
||||||
created_at: 1700000001,
|
|
||||||
status: 'completed',
|
|
||||||
model: 'gpt-5',
|
|
||||||
output: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(emitted.some((item) => item.event === 'response.custom_tool_call_input.done')).toBe(true);
|
|
||||||
expect(emitted.some((item) => item.event === 'response.image_generation_call.completed')).toBe(true);
|
|
||||||
|
|
||||||
const completed = emitted.find((item) => item.event === 'response.completed');
|
|
||||||
expect(completed?.data.response.output).toEqual([
|
|
||||||
{
|
|
||||||
id: 'ct_1',
|
|
||||||
type: 'custom_tool_call',
|
|
||||||
status: 'completed',
|
|
||||||
call_id: 'ct_1',
|
|
||||||
name: 'browser',
|
|
||||||
input: 'open example.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'img_1',
|
|
||||||
type: 'image_generation_call',
|
|
||||||
status: 'completed',
|
|
||||||
result: 'data:image/png;base64,final',
|
|
||||||
partial_images: [
|
partial_images: [
|
||||||
{
|
{
|
||||||
partial_image_index: 0,
|
partial_image_index: 0,
|
||||||
partial_image_b64: 'abc123',
|
partial_image_b64: 'partial',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
});
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -407,6 +407,26 @@ function serializeOriginalResponsesEvent(eventType: string, payload: Record<stri
|
|||||||
return [serializeSse(eventType, payload)];
|
return [serializeSse(eventType, payload)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeImageGenerationFields(
|
||||||
|
item: AggregateOutputItem,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const passthroughKeys = [
|
||||||
|
'background',
|
||||||
|
'output_format',
|
||||||
|
'quality',
|
||||||
|
'size',
|
||||||
|
'revised_prompt',
|
||||||
|
'mime_type',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const key of passthroughKeys) {
|
||||||
|
if (payload[key] !== undefined) {
|
||||||
|
item[key] = cloneJson(payload[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function computeNovelDelta(existingText: string, incomingDelta: string): string {
|
function computeNovelDelta(existingText: string, incomingDelta: string): string {
|
||||||
if (!incomingDelta) return '';
|
if (!incomingDelta) return '';
|
||||||
if (!existingText) return incomingDelta;
|
if (!existingText) return incomingDelta;
|
||||||
@@ -555,6 +575,7 @@ function applyOriginalResponsesPayload(
|
|||||||
});
|
});
|
||||||
entry.item.partial_images = partialImages;
|
entry.item.partial_images = partialImages;
|
||||||
}
|
}
|
||||||
|
mergeImageGenerationFields(entry.item, payload);
|
||||||
if (payload.result !== undefined) {
|
if (payload.result !== undefined) {
|
||||||
entry.item.result = payload.result;
|
entry.item.result = payload.result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,117 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildResponsesCompatibilityBodies(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const candidates: Record<string, unknown>[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
try {
|
||||||
|
const originalKey = JSON.stringify(body);
|
||||||
|
if (originalKey) seen.add(originalKey);
|
||||||
|
} catch {
|
||||||
|
// ignore non-serializable bodies
|
||||||
|
}
|
||||||
|
|
||||||
|
const push = (next: Record<string, unknown> | null) => {
|
||||||
|
if (!next) return;
|
||||||
|
let key = '';
|
||||||
|
try {
|
||||||
|
key = JSON.stringify(next);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
candidates.push(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(stripResponsesMetadata(body));
|
||||||
|
push(buildCoreResponsesBody(body));
|
||||||
|
push(buildStrictResponsesBody(body));
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildResponsesCompatibilityHeaderCandidates(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
stream: boolean,
|
||||||
|
): Record<string, string>[] {
|
||||||
|
const candidates: Record<string, string>[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const push = (next: Record<string, string>) => {
|
||||||
|
const normalizedEntries = Object.entries(next)
|
||||||
|
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0)
|
||||||
|
.map(([key, value]) => [key.toLowerCase(), value] as const)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const key = JSON.stringify(normalizedEntries);
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
candidates.push(Object.fromEntries(normalizedEntries));
|
||||||
|
};
|
||||||
|
|
||||||
|
push(headers);
|
||||||
|
|
||||||
|
const minimal: Record<string, string> = {};
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||||
|
const key = rawKey.toLowerCase();
|
||||||
|
if (
|
||||||
|
key === 'authorization'
|
||||||
|
|| key === 'x-api-key'
|
||||||
|
|| key === 'content-type'
|
||||||
|
|| key === 'accept'
|
||||||
|
) {
|
||||||
|
minimal[key] = rawValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!minimal['content-type']) minimal['content-type'] = 'application/json';
|
||||||
|
if (stream && !minimal.accept) minimal.accept = 'text/event-stream';
|
||||||
|
push(minimal);
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRetryResponsesCompatibility(input: {
|
||||||
|
endpoint: string;
|
||||||
|
status: number;
|
||||||
|
rawErrText: string;
|
||||||
|
}): boolean {
|
||||||
|
if (input.endpoint !== 'responses') return false;
|
||||||
|
if (input.status !== 400) return false;
|
||||||
|
const parsedError = parseUpstreamErrorShape(input.rawErrText);
|
||||||
|
const type = parsedError.type.trim().toLowerCase();
|
||||||
|
const code = parsedError.code.trim().toLowerCase();
|
||||||
|
const message = parsedError.message.trim().toLowerCase();
|
||||||
|
const compact = `${type} ${code} ${message}`.trim();
|
||||||
|
const rawCompact = (input.rawErrText || '').toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
compact.includes('invalid_api_key')
|
||||||
|
|| compact.includes('authentication')
|
||||||
|
|| compact.includes('unauthorized')
|
||||||
|
|| compact.includes('forbidden')
|
||||||
|
|| compact.includes('insufficient_quota')
|
||||||
|
|| compact.includes('rate_limit')
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldDowngradeResponsesChatToMessages(
|
||||||
|
endpointPath: string,
|
||||||
|
status: number,
|
||||||
|
upstreamErrorText: string,
|
||||||
|
): boolean {
|
||||||
|
if (!endpointPath.includes('/chat/completions')) return false;
|
||||||
|
if (status < 400 || status >= 500) return false;
|
||||||
|
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
||||||
|
}
|
||||||
|
|
||||||
function extractTextFromResponsesContent(content: unknown): string {
|
function extractTextFromResponsesContent(content: unknown): string {
|
||||||
if (typeof content === 'string') return content.trim();
|
if (typeof content === 'string') return content.trim();
|
||||||
if (!Array.isArray(content)) return '';
|
if (!Array.isArray(content)) return '';
|
||||||
@@ -216,6 +327,79 @@ function toFiniteNumber(value: unknown): number | null {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseUpstreamErrorShape(rawText: string): {
|
||||||
|
type: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawText) as Record<string, unknown>;
|
||||||
|
const error = (parsed.error && typeof parsed.error === 'object')
|
||||||
|
? parsed.error as Record<string, unknown>
|
||||||
|
: parsed;
|
||||||
|
return {
|
||||||
|
type: typeof error.type === 'string' ? error.type.trim().toLowerCase() : '',
|
||||||
|
code: typeof error.code === 'string' ? error.code.trim().toLowerCase() : '',
|
||||||
|
message: typeof error.message === 'string' ? error.message.trim() : '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { type: '', code: '', message: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripResponsesMetadata(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(body, 'metadata')) return null;
|
||||||
|
const next = { ...body };
|
||||||
|
delete next.metadata;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCoreResponsesBody(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
||||||
|
if (!model) return null;
|
||||||
|
if (body.input === undefined) return null;
|
||||||
|
|
||||||
|
const core: Record<string, unknown> = {
|
||||||
|
model,
|
||||||
|
input: body.input,
|
||||||
|
stream: body.stream === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxOutputTokens = toFiniteNumber(body.max_output_tokens);
|
||||||
|
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||||
|
core.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
const temperature = toFiniteNumber(body.temperature);
|
||||||
|
if (temperature !== null) core.temperature = temperature;
|
||||||
|
|
||||||
|
const topP = toFiniteNumber(body.top_p);
|
||||||
|
if (topP !== null) core.top_p = topP;
|
||||||
|
|
||||||
|
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||||
|
if (instructions) core.instructions = instructions;
|
||||||
|
|
||||||
|
return core;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStrictResponsesBody(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
||||||
|
if (!model) return null;
|
||||||
|
if (body.input === undefined) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
input: body.input,
|
||||||
|
stream: body.stream === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||||
'model',
|
'model',
|
||||||
'input',
|
'input',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
|||||||
stream_options: { include_obfuscation: true },
|
stream_options: { include_obfuscation: true },
|
||||||
background: true,
|
background: true,
|
||||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||||
|
top_logprobs: 2,
|
||||||
},
|
},
|
||||||
'gpt-5',
|
'gpt-5',
|
||||||
true,
|
true,
|
||||||
@@ -34,6 +35,48 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
|||||||
stream_options: { include_obfuscation: true },
|
stream_options: { include_obfuscation: true },
|
||||||
background: true,
|
background: true,
|
||||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||||
|
top_logprobs: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes current Responses inbound parity fields', () => {
|
||||||
|
const result = sanitizeResponsesBodyForProxy(
|
||||||
|
{
|
||||||
|
input: 'hello',
|
||||||
|
safety_identifier: ' safe-user-4 ',
|
||||||
|
max_tool_calls: '5',
|
||||||
|
prompt_cache_key: ' cache-key-2 ',
|
||||||
|
prompt_cache_retention: ' 24h ',
|
||||||
|
stream_options: { include_obfuscation: 'true', extra: 'keep-me' },
|
||||||
|
background: 'false',
|
||||||
|
text: { format: { type: 'text' }, verbosity: ' high ' },
|
||||||
|
truncation: ' auto ',
|
||||||
|
previous_response_id: ' resp_prev_2 ',
|
||||||
|
include: [' reasoning.encrypted_content ', '', 123, 'message.input_image.image_url'],
|
||||||
|
top_logprobs: '7',
|
||||||
|
user: ' user-456 ',
|
||||||
|
service_tier: ' priority ',
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
model: 'gpt-5',
|
||||||
|
stream: false,
|
||||||
|
safety_identifier: 'safe-user-4',
|
||||||
|
max_tool_calls: 5,
|
||||||
|
prompt_cache_key: 'cache-key-2',
|
||||||
|
prompt_cache_retention: '24h',
|
||||||
|
stream_options: { include_obfuscation: true, extra: 'keep-me' },
|
||||||
|
background: false,
|
||||||
|
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||||
|
truncation: 'auto',
|
||||||
|
previous_response_id: 'resp_prev_2',
|
||||||
|
include: ['reasoning.encrypted_content', 'message.input_image.image_url'],
|
||||||
|
top_logprobs: 7,
|
||||||
|
user: 'user-456',
|
||||||
|
service_tier: 'priority',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -94,6 +137,92 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps OpenAI response_format into Responses text.format while preserving verbosity', () => {
|
||||||
|
const result = convertOpenAiBodyToResponsesBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [{ role: 'user', content: 'return structured data' }],
|
||||||
|
response_format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'payload',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verbosity: 'high',
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'payload',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verbosity: 'high',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes and preserves field parity when converting from OpenAI-compatible input', () => {
|
||||||
|
const result = convertOpenAiBodyToResponsesBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
safety_identifier: ' safe-user-5 ',
|
||||||
|
max_tool_calls: '6',
|
||||||
|
prompt_cache_key: ' cache-key-3 ',
|
||||||
|
prompt_cache_retention: ' in-memory ',
|
||||||
|
stream_options: { include_obfuscation: 'false' },
|
||||||
|
background: 'true',
|
||||||
|
verbosity: ' medium ',
|
||||||
|
truncation: ' disabled ',
|
||||||
|
previous_response_id: ' resp_prev_3 ',
|
||||||
|
include: 'reasoning.encrypted_content',
|
||||||
|
top_logprobs: '4',
|
||||||
|
user: ' user-789 ',
|
||||||
|
service_tier: ' flex ',
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
model: 'gpt-5',
|
||||||
|
stream: true,
|
||||||
|
safety_identifier: 'safe-user-5',
|
||||||
|
max_tool_calls: 6,
|
||||||
|
prompt_cache_key: 'cache-key-3',
|
||||||
|
prompt_cache_retention: 'in-memory',
|
||||||
|
stream_options: { include_obfuscation: false },
|
||||||
|
background: true,
|
||||||
|
text: { verbosity: 'medium' },
|
||||||
|
truncation: 'disabled',
|
||||||
|
previous_response_id: 'resp_prev_3',
|
||||||
|
include: ['reasoning.encrypted_content'],
|
||||||
|
top_logprobs: 4,
|
||||||
|
user: 'user-789',
|
||||||
|
service_tier: 'flex',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('convertResponsesBodyToOpenAiBody', () => {
|
describe('convertResponsesBodyToOpenAiBody', () => {
|
||||||
@@ -258,4 +387,88 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
|||||||
top_logprobs: 4,
|
top_logprobs: 4,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps Responses text.format back into OpenAI response_format', () => {
|
||||||
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'payload',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
value: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verbosity: 'medium',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
response_format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'payload',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
value: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verbosity: 'medium',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes field parity when converting Responses input back to OpenAI-compatible input', () => {
|
||||||
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: 'hello',
|
||||||
|
safety_identifier: ' safe-user-6 ',
|
||||||
|
max_tool_calls: '8',
|
||||||
|
prompt_cache_key: ' cache-key-4 ',
|
||||||
|
prompt_cache_retention: ' 24h ',
|
||||||
|
stream_options: { include_obfuscation: 'true' },
|
||||||
|
background: 'false',
|
||||||
|
text: { verbosity: ' low ' },
|
||||||
|
truncation: ' auto ',
|
||||||
|
previous_response_id: ' resp_prev_4 ',
|
||||||
|
include: [' reasoning.encrypted_content ', ''],
|
||||||
|
top_logprobs: '9',
|
||||||
|
user: ' user-999 ',
|
||||||
|
service_tier: ' default ',
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
model: 'gpt-5',
|
||||||
|
stream: false,
|
||||||
|
safety_identifier: 'safe-user-6',
|
||||||
|
max_tool_calls: 8,
|
||||||
|
prompt_cache_key: 'cache-key-4',
|
||||||
|
prompt_cache_retention: '24h',
|
||||||
|
stream_options: { include_obfuscation: true },
|
||||||
|
background: false,
|
||||||
|
verbosity: 'low',
|
||||||
|
truncation: 'auto',
|
||||||
|
previous_response_id: 'resp_prev_4',
|
||||||
|
include: ['reasoning.encrypted_content'],
|
||||||
|
top_logprobs: 9,
|
||||||
|
user: 'user-999',
|
||||||
|
service_tier: 'default',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,29 @@ function toFiniteNumber(value: unknown): number | null {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toFiniteIntegerLike(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return Math.trunc(value);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBooleanLike(value: unknown): boolean | undefined {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim().toLowerCase();
|
||||||
|
if (trimmed === 'true') return true;
|
||||||
|
if (trimmed === 'false') return false;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function safeJsonStringify(value: unknown): string {
|
function safeJsonStringify(value: unknown): string {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
@@ -40,6 +63,103 @@ function cloneJsonValue<T>(value: T): T {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalTrimmedString(value: unknown): string | undefined {
|
||||||
|
const trimmed = asTrimmedString(value);
|
||||||
|
return trimmed || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIncludeList(value: unknown): unknown {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? [trimmed] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(value)) return value;
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTextConfig(
|
||||||
|
rawText: unknown,
|
||||||
|
fallbackVerbosity?: unknown,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const textConfig = cloneRecord(rawText) || {};
|
||||||
|
const verbosity = (
|
||||||
|
normalizeOptionalTrimmedString(textConfig.verbosity)
|
||||||
|
?? normalizeOptionalTrimmedString(fallbackVerbosity)
|
||||||
|
);
|
||||||
|
if (verbosity) {
|
||||||
|
textConfig.verbosity = verbosity;
|
||||||
|
}
|
||||||
|
return Object.keys(textConfig).length > 0 ? textConfig : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStreamOptions(rawStreamOptions: unknown): unknown {
|
||||||
|
const streamOptions = cloneRecord(rawStreamOptions);
|
||||||
|
if (!streamOptions) return rawStreamOptions;
|
||||||
|
|
||||||
|
const includeObfuscation = toBooleanLike(streamOptions.include_obfuscation);
|
||||||
|
if (includeObfuscation !== undefined) {
|
||||||
|
streamOptions.include_obfuscation = includeObfuscation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponsesRequestFieldParity(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
options?: { verbositySource?: unknown },
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const normalized: Record<string, unknown> = { ...body };
|
||||||
|
|
||||||
|
const safetyIdentifier = normalizeOptionalTrimmedString(normalized.safety_identifier);
|
||||||
|
if (safetyIdentifier) normalized.safety_identifier = safetyIdentifier;
|
||||||
|
|
||||||
|
const maxToolCalls = toFiniteIntegerLike(normalized.max_tool_calls);
|
||||||
|
if (maxToolCalls !== null) normalized.max_tool_calls = maxToolCalls;
|
||||||
|
|
||||||
|
const promptCacheKey = normalizeOptionalTrimmedString(normalized.prompt_cache_key);
|
||||||
|
if (promptCacheKey) normalized.prompt_cache_key = promptCacheKey;
|
||||||
|
|
||||||
|
const promptCacheRetention = normalizeOptionalTrimmedString(normalized.prompt_cache_retention);
|
||||||
|
if (promptCacheRetention) normalized.prompt_cache_retention = promptCacheRetention;
|
||||||
|
|
||||||
|
const background = toBooleanLike(normalized.background);
|
||||||
|
if (background !== undefined) normalized.background = background;
|
||||||
|
|
||||||
|
const user = normalizeOptionalTrimmedString(normalized.user);
|
||||||
|
if (user) normalized.user = user;
|
||||||
|
|
||||||
|
const previousResponseId = normalizeOptionalTrimmedString(normalized.previous_response_id);
|
||||||
|
if (previousResponseId) normalized.previous_response_id = previousResponseId;
|
||||||
|
|
||||||
|
const truncation = normalizeOptionalTrimmedString(normalized.truncation);
|
||||||
|
if (truncation) normalized.truncation = truncation;
|
||||||
|
|
||||||
|
const serviceTier = normalizeOptionalTrimmedString(normalized.service_tier);
|
||||||
|
if (serviceTier) normalized.service_tier = serviceTier;
|
||||||
|
|
||||||
|
const topLogprobs = toFiniteIntegerLike(normalized.top_logprobs);
|
||||||
|
if (topLogprobs !== null) normalized.top_logprobs = topLogprobs;
|
||||||
|
|
||||||
|
if (normalized.include !== undefined) {
|
||||||
|
normalized.include = normalizeIncludeList(normalized.include);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.stream_options !== undefined) {
|
||||||
|
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textConfig = normalizeTextConfig(normalized.text, options?.verbositySource);
|
||||||
|
if (textConfig) {
|
||||||
|
normalized.text = textConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function parseJsonString(raw: string): unknown {
|
function parseJsonString(raw: string): unknown {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return {};
|
if (!trimmed) return {};
|
||||||
@@ -219,6 +339,7 @@ const ALLOWED_RESPONSES_FIELDS = new Set([
|
|||||||
'include',
|
'include',
|
||||||
'response_format',
|
'response_format',
|
||||||
'service_tier',
|
'service_tier',
|
||||||
|
'top_logprobs',
|
||||||
'stop',
|
'stop',
|
||||||
'n',
|
'n',
|
||||||
]);
|
]);
|
||||||
@@ -250,6 +371,10 @@ export function sanitizeResponsesBodyForProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalized = normalizeResponsesRequestFieldParity(normalized, {
|
||||||
|
verbositySource: body.verbosity,
|
||||||
|
});
|
||||||
|
|
||||||
const sanitized: Record<string, unknown> = {};
|
const sanitized: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(normalized)) {
|
for (const [key, value] of Object.entries(normalized)) {
|
||||||
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||||
@@ -388,23 +513,23 @@ export function convertOpenAiBodyToResponsesBody(
|
|||||||
if (openaiBody.service_tier !== undefined) body.service_tier = openaiBody.service_tier;
|
if (openaiBody.service_tier !== undefined) body.service_tier = openaiBody.service_tier;
|
||||||
if (openaiBody.top_logprobs !== undefined) body.top_logprobs = openaiBody.top_logprobs;
|
if (openaiBody.top_logprobs !== undefined) body.top_logprobs = openaiBody.top_logprobs;
|
||||||
if (openaiBody.stream_options !== undefined) body.stream_options = openaiBody.stream_options;
|
if (openaiBody.stream_options !== undefined) body.stream_options = openaiBody.stream_options;
|
||||||
|
if (openaiBody.response_format !== undefined) {
|
||||||
const textConfig = cloneRecord(openaiBody.text) || {};
|
const existingTextConfig = cloneRecord(body.text) || {};
|
||||||
const verbosity = (
|
existingTextConfig.format = cloneJsonValue(openaiBody.response_format);
|
||||||
asTrimmedString(openaiBody.verbosity)
|
body.text = existingTextConfig;
|
||||||
|| asTrimmedString(isRecord(openaiBody.text) ? openaiBody.text.verbosity : undefined)
|
|
||||||
);
|
|
||||||
if (verbosity) {
|
|
||||||
textConfig.verbosity = verbosity;
|
|
||||||
}
|
}
|
||||||
if (Object.keys(textConfig).length > 0) {
|
|
||||||
|
const textConfig = normalizeTextConfig(body.text, openaiBody.verbosity);
|
||||||
|
if (textConfig) {
|
||||||
body.text = textConfig;
|
body.text = textConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
||||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||||
|
|
||||||
return normalizeResponsesBodyForCompatibility(body);
|
return normalizeResponsesBodyForCompatibility(
|
||||||
|
normalizeResponsesRequestFieldParity(body, { verbositySource: openaiBody.verbosity }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenAiToolCall = {
|
type OpenAiToolCall = {
|
||||||
@@ -560,8 +685,9 @@ export function convertResponsesBodyToOpenAiBody(
|
|||||||
modelName: string,
|
modelName: string,
|
||||||
stream: boolean,
|
stream: boolean,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
const normalizedBody = normalizeResponsesRequestFieldParity(body);
|
||||||
const messages: Array<Record<string, unknown>> = [];
|
const messages: Array<Record<string, unknown>> = [];
|
||||||
const input = body.input;
|
const input = normalizedBody.input;
|
||||||
let functionCallIndex = 0;
|
let functionCallIndex = 0;
|
||||||
let pendingToolCalls: OpenAiToolCall[] = [];
|
let pendingToolCalls: OpenAiToolCall[] = [];
|
||||||
|
|
||||||
@@ -655,7 +781,7 @@ export function convertResponsesBodyToOpenAiBody(
|
|||||||
}
|
}
|
||||||
flushPendingToolCalls();
|
flushPendingToolCalls();
|
||||||
|
|
||||||
const instructions = asTrimmedString(body.instructions);
|
const instructions = asTrimmedString(normalizedBody.instructions);
|
||||||
if (instructions) {
|
if (instructions) {
|
||||||
messages.unshift({ role: 'system', content: instructions });
|
messages.unshift({ role: 'system', content: instructions });
|
||||||
}
|
}
|
||||||
@@ -666,32 +792,35 @@ export function convertResponsesBodyToOpenAiBody(
|
|||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) {
|
if (typeof normalizedBody.temperature === 'number' && Number.isFinite(normalizedBody.temperature)) {
|
||||||
payload.temperature = body.temperature;
|
payload.temperature = normalizedBody.temperature;
|
||||||
}
|
}
|
||||||
if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) {
|
if (typeof normalizedBody.top_p === 'number' && Number.isFinite(normalizedBody.top_p)) {
|
||||||
payload.top_p = body.top_p;
|
payload.top_p = normalizedBody.top_p;
|
||||||
}
|
}
|
||||||
if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) {
|
if (typeof normalizedBody.max_output_tokens === 'number' && Number.isFinite(normalizedBody.max_output_tokens)) {
|
||||||
payload.max_tokens = body.max_output_tokens;
|
payload.max_tokens = normalizedBody.max_output_tokens;
|
||||||
}
|
}
|
||||||
if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls;
|
if (normalizedBody.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls;
|
||||||
if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools);
|
if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools);
|
||||||
if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice);
|
if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice);
|
||||||
if (body.safety_identifier !== undefined) payload.safety_identifier = body.safety_identifier;
|
if (normalizedBody.safety_identifier !== undefined) payload.safety_identifier = normalizedBody.safety_identifier;
|
||||||
if (body.max_tool_calls !== undefined) payload.max_tool_calls = body.max_tool_calls;
|
if (normalizedBody.max_tool_calls !== undefined) payload.max_tool_calls = normalizedBody.max_tool_calls;
|
||||||
if (body.prompt_cache_key !== undefined) payload.prompt_cache_key = body.prompt_cache_key;
|
if (normalizedBody.prompt_cache_key !== undefined) payload.prompt_cache_key = normalizedBody.prompt_cache_key;
|
||||||
if (body.prompt_cache_retention !== undefined) payload.prompt_cache_retention = body.prompt_cache_retention;
|
if (normalizedBody.prompt_cache_retention !== undefined) payload.prompt_cache_retention = normalizedBody.prompt_cache_retention;
|
||||||
if (body.background !== undefined) payload.background = body.background;
|
if (normalizedBody.background !== undefined) payload.background = normalizedBody.background;
|
||||||
if (body.user !== undefined) payload.user = body.user;
|
if (normalizedBody.user !== undefined) payload.user = normalizedBody.user;
|
||||||
if (body.include !== undefined) payload.include = cloneJsonValue(body.include);
|
if (normalizedBody.include !== undefined) payload.include = cloneJsonValue(normalizedBody.include);
|
||||||
if (body.previous_response_id !== undefined) payload.previous_response_id = body.previous_response_id;
|
if (normalizedBody.previous_response_id !== undefined) payload.previous_response_id = normalizedBody.previous_response_id;
|
||||||
if (body.truncation !== undefined) payload.truncation = body.truncation;
|
if (normalizedBody.truncation !== undefined) payload.truncation = normalizedBody.truncation;
|
||||||
if (body.service_tier !== undefined) payload.service_tier = body.service_tier;
|
if (normalizedBody.service_tier !== undefined) payload.service_tier = normalizedBody.service_tier;
|
||||||
if (body.top_logprobs !== undefined) payload.top_logprobs = body.top_logprobs;
|
if (normalizedBody.top_logprobs !== undefined) payload.top_logprobs = normalizedBody.top_logprobs;
|
||||||
if (body.stream_options !== undefined) payload.stream_options = body.stream_options;
|
if (normalizedBody.stream_options !== undefined) payload.stream_options = normalizedBody.stream_options;
|
||||||
if (isRecord(body.text) && asTrimmedString(body.text.verbosity)) {
|
if (isRecord(normalizedBody.text) && normalizedBody.text.format !== undefined) {
|
||||||
payload.verbosity = asTrimmedString(body.text.verbosity);
|
payload.response_format = cloneJsonValue(normalizedBody.text.format);
|
||||||
|
}
|
||||||
|
if (isRecord(normalizedBody.text) && asTrimmedString(normalizedBody.text.verbosity)) {
|
||||||
|
payload.verbosity = asTrimmedString(normalizedBody.text.verbosity);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import { type StreamTransformContext } from '../../shared/normalized.js';
|
|||||||
import {
|
import {
|
||||||
convertOpenAiBodyToResponsesBody,
|
convertOpenAiBodyToResponsesBody,
|
||||||
convertResponsesBodyToOpenAiBody,
|
convertResponsesBodyToOpenAiBody,
|
||||||
normalizeResponsesInputForCompatibility,
|
|
||||||
normalizeResponsesMessageContent,
|
|
||||||
sanitizeResponsesBodyForProxy,
|
sanitizeResponsesBodyForProxy,
|
||||||
} from './conversion.js';
|
} from './conversion.js';
|
||||||
import { normalizeResponsesMessageItem } from './compatibility.js';
|
import {
|
||||||
|
buildResponsesCompatibilityBodies as buildRetryBodies,
|
||||||
|
buildResponsesCompatibilityHeaderCandidates as buildRetryHeaders,
|
||||||
|
normalizeResponsesInputForCompatibility,
|
||||||
|
normalizeResponsesMessageContent,
|
||||||
|
normalizeResponsesMessageItem,
|
||||||
|
shouldDowngradeResponsesChatToMessages as shouldDowngradeChatToMessages,
|
||||||
|
shouldRetryResponsesCompatibility as shouldRetry,
|
||||||
|
} from './compatibility.js';
|
||||||
import {
|
import {
|
||||||
type OpenAiResponsesAggregateState,
|
type OpenAiResponsesAggregateState,
|
||||||
completeResponsesStream,
|
completeResponsesStream,
|
||||||
@@ -31,6 +37,12 @@ export const openAiResponsesTransformer = {
|
|||||||
outbound: openAiResponsesOutbound,
|
outbound: openAiResponsesOutbound,
|
||||||
stream: openAiResponsesStream,
|
stream: openAiResponsesStream,
|
||||||
usage: openAiResponsesUsage,
|
usage: openAiResponsesUsage,
|
||||||
|
compatibility: {
|
||||||
|
buildRetryBodies,
|
||||||
|
buildRetryHeaders,
|
||||||
|
shouldRetry,
|
||||||
|
shouldDowngradeChatToMessages,
|
||||||
|
},
|
||||||
aggregator: {
|
aggregator: {
|
||||||
createState: createOpenAiResponsesAggregateState,
|
createState: createOpenAiResponsesAggregateState,
|
||||||
serialize: serializeConvertedResponsesEvents,
|
serialize: serializeConvertedResponsesEvents,
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { serializeResponsesFinalPayload } from './outbound.js';
|
||||||
|
|
||||||
|
describe('serializeResponsesFinalPayload', () => {
|
||||||
|
it('preserves top-level chat annotations on synthetic assistant messages', () => {
|
||||||
|
const payload = serializeResponsesFinalPayload({
|
||||||
|
upstreamPayload: {
|
||||||
|
id: 'chatcmpl_1',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1700000000,
|
||||||
|
model: 'gpt-5',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'hello',
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
type: 'url_citation',
|
||||||
|
url: 'https://example.com',
|
||||||
|
title: 'Example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
id: 'chatcmpl_1',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 1700000000,
|
||||||
|
content: 'hello',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'stop',
|
||||||
|
toolCalls: [],
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.output).toEqual([
|
||||||
|
{
|
||||||
|
id: 'msg_chatcmpl_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'hello',
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
type: 'url_citation',
|
||||||
|
url: 'https://example.com',
|
||||||
|
title: 'Example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to normalized tool calls when upstream payload no longer exposes them directly', () => {
|
||||||
|
const payload = serializeResponsesFinalPayload({
|
||||||
|
upstreamPayload: {
|
||||||
|
id: 'opaque_1',
|
||||||
|
model: 'gpt-5',
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
id: 'opaque_1',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 1700000000,
|
||||||
|
content: '',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'tool_calls',
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'browser',
|
||||||
|
arguments: '{"url":"https://example.com"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.output).toEqual([
|
||||||
|
{
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function_call',
|
||||||
|
status: 'completed',
|
||||||
|
call_id: 'call_1',
|
||||||
|
name: 'browser',
|
||||||
|
arguments: '{"url":"https://example.com"}',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => {
|
||||||
|
const payload = serializeResponsesFinalPayload({
|
||||||
|
upstreamPayload: {
|
||||||
|
id: 'resp_like_1',
|
||||||
|
model: 'gpt-5',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'ct_1',
|
||||||
|
type: 'custom_tool_call',
|
||||||
|
status: 'completed',
|
||||||
|
call_id: 'ct_1',
|
||||||
|
name: 'browser',
|
||||||
|
input: 'open example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'img_1',
|
||||||
|
type: 'image_generation_call',
|
||||||
|
status: 'completed',
|
||||||
|
result: 'data:image/png;base64,final',
|
||||||
|
background: 'transparent',
|
||||||
|
output_format: 'png',
|
||||||
|
quality: 'high',
|
||||||
|
size: '1024x1024',
|
||||||
|
partial_images: [
|
||||||
|
{
|
||||||
|
partial_image_index: 0,
|
||||||
|
partial_image_b64: 'partial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
id: 'resp_like_1',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 1700000000,
|
||||||
|
content: '',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'stop',
|
||||||
|
toolCalls: [],
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.output).toEqual([
|
||||||
|
{
|
||||||
|
id: 'ct_1',
|
||||||
|
type: 'custom_tool_call',
|
||||||
|
status: 'completed',
|
||||||
|
call_id: 'ct_1',
|
||||||
|
name: 'browser',
|
||||||
|
input: 'open example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'img_1',
|
||||||
|
type: 'image_generation_call',
|
||||||
|
status: 'completed',
|
||||||
|
result: 'data:image/png;base64,final',
|
||||||
|
background: 'transparent',
|
||||||
|
output_format: 'png',
|
||||||
|
quality: 'high',
|
||||||
|
size: '1024x1024',
|
||||||
|
partial_images: [
|
||||||
|
{
|
||||||
|
partial_image_index: 0,
|
||||||
|
partial_image_b64: 'partial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps upstream chat-completion usage details into Responses usage details', () => {
|
||||||
|
const payload = serializeResponsesFinalPayload({
|
||||||
|
upstreamPayload: {
|
||||||
|
id: 'chatcmpl_usage',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1700000000,
|
||||||
|
model: 'gpt-5',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'hello',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 11,
|
||||||
|
completion_tokens: 7,
|
||||||
|
total_tokens: 18,
|
||||||
|
prompt_tokens_details: {
|
||||||
|
cached_tokens: 5,
|
||||||
|
audio_tokens: 2,
|
||||||
|
},
|
||||||
|
completion_tokens_details: {
|
||||||
|
reasoning_tokens: 3,
|
||||||
|
audio_tokens: 1,
|
||||||
|
accepted_prediction_tokens: 4,
|
||||||
|
rejected_prediction_tokens: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
id: 'chatcmpl_usage',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 1700000000,
|
||||||
|
content: 'hello',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'stop',
|
||||||
|
toolCalls: [],
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
promptTokens: 11,
|
||||||
|
completionTokens: 7,
|
||||||
|
totalTokens: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.usage).toMatchObject({
|
||||||
|
input_tokens: 11,
|
||||||
|
output_tokens: 7,
|
||||||
|
total_tokens: 18,
|
||||||
|
input_tokens_details: {
|
||||||
|
cached_tokens: 5,
|
||||||
|
audio_tokens: 2,
|
||||||
|
},
|
||||||
|
output_tokens_details: {
|
||||||
|
reasoning_tokens: 3,
|
||||||
|
audio_tokens: 1,
|
||||||
|
accepted_prediction_tokens: 4,
|
||||||
|
rejected_prediction_tokens: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,18 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
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 {
|
function ensureResponseId(rawId: string): string {
|
||||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||||
@@ -41,6 +53,8 @@ export type ResponsesUsageSummary = {
|
|||||||
promptTokens: number;
|
promptTokens: number;
|
||||||
completionTokens: number;
|
completionTokens: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
|
inputTokensDetails?: Record<string, unknown>;
|
||||||
|
outputTokensDetails?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResponsesToolCall = {
|
export type ResponsesToolCall = {
|
||||||
@@ -49,6 +63,8 @@ export type ResponsesToolCall = {
|
|||||||
arguments: string;
|
arguments: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResponsesOutputItem = Record<string, unknown>;
|
||||||
|
|
||||||
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||||
if (!isRecord(payload)) return [];
|
if (!isRecord(payload)) return [];
|
||||||
|
|
||||||
@@ -95,6 +111,166 @@ function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
|||||||
return [];
|
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.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) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
export function normalizeResponsesFinalPayload(
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
modelName: string,
|
modelName: string,
|
||||||
@@ -118,10 +294,30 @@ export function serializeResponsesFinalPayload(input: {
|
|||||||
: `resp_${Date.now()}`;
|
: `resp_${Date.now()}`;
|
||||||
const responseId = ensureResponseId(normalizedId);
|
const responseId = ensureResponseId(normalizedId);
|
||||||
const messageId = ensureMessageId(normalizedId);
|
const messageId = ensureMessageId(normalizedId);
|
||||||
const toolCalls = extractToolCallsFromUpstream(upstreamPayload);
|
const syntheticOutput = extractSyntheticOutputItemsFromUpstream(upstreamPayload);
|
||||||
|
const syntheticUsageDetails = extractSyntheticUsageDetails(upstreamPayload);
|
||||||
|
const toolCalls = (() => {
|
||||||
|
const extracted = extractToolCallsFromUpstream(upstreamPayload);
|
||||||
|
if (extracted.length > 0) return extracted;
|
||||||
|
return extractToolCallsFromNormalized(normalized);
|
||||||
|
})();
|
||||||
|
const annotations = extractAnnotationsFromUpstream(upstreamPayload);
|
||||||
|
|
||||||
const output: Array<Record<string, unknown>> = [];
|
const output: Array<Record<string, unknown>> = syntheticOutput.map((item) => cloneJson(item));
|
||||||
if (normalized.reasoningContent) {
|
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 && !hasReasoningItem) {
|
||||||
output.push({
|
output.push({
|
||||||
id: ensureMessageId(`${normalizedId}_reasoning`),
|
id: ensureMessageId(`${normalizedId}_reasoning`),
|
||||||
type: 'reasoning',
|
type: 'reasoning',
|
||||||
@@ -132,19 +328,25 @@ export function serializeResponsesFinalPayload(input: {
|
|||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (normalized.content || toolCalls.length === 0) {
|
|
||||||
|
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({
|
output.push({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
content: [{
|
content: [textPart],
|
||||||
type: 'output_text',
|
|
||||||
text: normalized.content || '',
|
|
||||||
}],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toolCalls.length > 0 && !hasToolLikeItem) {
|
||||||
for (const toolCall of toolCalls) {
|
for (const toolCall of toolCalls) {
|
||||||
output.push({
|
output.push({
|
||||||
id: toolCall.id,
|
id: toolCall.id,
|
||||||
@@ -155,6 +357,7 @@ export function serializeResponsesFinalPayload(input: {
|
|||||||
arguments: toolCall.arguments,
|
arguments: toolCall.arguments,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: responseId,
|
id: responseId,
|
||||||
@@ -163,11 +366,17 @@ export function serializeResponsesFinalPayload(input: {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
model: normalized.model,
|
model: normalized.model,
|
||||||
output,
|
output,
|
||||||
output_text: normalized.content || '',
|
output_text: normalized.content || collectOutputTextFromItems(output),
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: usage.promptTokens,
|
input_tokens: usage.promptTokens,
|
||||||
output_tokens: usage.completionTokens,
|
output_tokens: usage.completionTokens,
|
||||||
total_tokens: usage.totalTokens,
|
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) }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user