Refactor reasoning handling and metadata management in responses

- Updated chat stream tests to clarify reasoning behavior and ensure correct handling of include lists.
- Enhanced responses proxy route to normalize include lists and apply default reasoning includes based on request context.
- Improved transformer metadata handling, ensuring proper serialization and deserialization of metadata records.
- Added tests for preserving reasoning signatures and managing citations in OpenAI-compatible responses.
- Refined the logic for injecting default includes in responses and ensured compatibility with codex surface defaults.
This commit is contained in:
cita
2026-03-08 23:03:33 +08:00
parent 7bd3114ea0
commit 3dcf45851f
12 changed files with 830 additions and 56 deletions
@@ -358,6 +358,41 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
]);
});
it('preserves top-level reasoning_signature when rebuilding assistant thinking blocks for messages fallback', () => {
const body = convertOpenAiBodyToAnthropicMessagesBody(
{
model: 'gpt-5',
messages: [
{
role: 'assistant',
content: 'final answer',
reasoning_content: 'internal only',
reasoning_signature: 'metapi:anthropic-signature:sig-top-level-1',
},
],
},
'claude-opus-4-6',
false,
);
expect(body.messages).toEqual([
{
role: 'assistant',
content: [
{
type: 'thinking',
thinking: 'internal only',
signature: 'sig-top-level-1',
},
{
type: 'text',
text: 'final answer',
},
],
},
]);
});
it('strips cache_control from thinking blocks and empty text blocks', () => {
const body = convertOpenAiBodyToAnthropicMessagesBody(
{
@@ -301,9 +301,10 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') {
const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data);
if (!text) return null;
const signature = type === 'redacted_thinking' ? undefined : resolveAnthropicThinkingSignature(item);
if (signature === null) return null;
const hasThinkingCarrier = type !== 'redacted_thinking' && signature !== undefined;
if (!text && !hasThinkingCarrier) return null;
const next: Record<string, unknown> = { ...item };
if (type === 'redacted_thinking') {
next.type = 'redacted_thinking';
@@ -772,6 +773,15 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
if (role !== 'assistant') return;
const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content);
const reasoningCarrier = sanitizeAnthropicContentBlock({
type: 'thinking',
thinking: asTrimmedString(item.reasoning_content ?? item.reasoning),
reasoning_signature: item.reasoning_signature,
signature: item.signature,
});
if (reasoningCarrier) {
contentBlocks.unshift(reasoningCarrier);
}
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
for (let index = 0; index < rawToolCalls.length; index += 1) {
@@ -5,6 +5,7 @@ import {
createGeminiGenerateContentAggregateState,
} from './aggregator.js';
import {
extractTransformerMetadata,
extractResponseMetadata,
serializeGeminiAggregateResponse,
} from './outbound.js';
@@ -240,6 +241,60 @@ describe('Gemini aggregate/outbound/usage', () => {
});
});
it('projects Gemini richer metadata into the shared transformer metadata contract', () => {
const state = createGeminiGenerateContentAggregateState();
applyGeminiGenerateContentAggregate(state, {
responseId: 'resp-shared-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-final' },
{ text: 'answer' },
],
},
},
],
usageMetadata: {
promptTokenCount: 15,
candidatesTokenCount: 5,
totalTokenCount: 24,
},
});
const metadata = extractTransformerMetadata(state, {
cachedContent: 'cached/item-1',
safetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }],
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
generationConfig: {
imageConfig: { aspectRatio: '16:9' },
},
});
expect(metadata).toEqual({
citations: [{ citations: [{ uri: 'https://example.com/cats' }] }],
groundingMetadata: [{ webSearchQueries: ['cats'] }],
thoughtSignature: 'sig-final',
thoughtSignatures: ['sig-final'],
usageMetadata: {
promptTokenCount: 15,
candidatesTokenCount: 5,
totalTokenCount: 24,
},
geminiSafetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }],
geminiImageConfig: { aspectRatio: '16:9' },
passthrough: {
cachedContent: 'cached/item-1',
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
},
});
});
it('merges split candidate metadata and emits top-level metadata in candidate order', () => {
const state = createGeminiGenerateContentAggregateState();
applyGeminiGenerateContentAggregate(state, [
@@ -3,6 +3,10 @@ import {
createGeminiGenerateContentAggregateState,
type GeminiGenerateContentAggregateState,
} from './aggregator.js';
import {
toTransformerMetadataRecord,
type TransformerMetadata,
} from '../../shared/normalized.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
@@ -176,14 +180,28 @@ function extractOrderedThoughtSignatures(
};
}
function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
function ensurePassthrough(metadata: TransformerMetadata): Record<string, unknown> {
if (!metadata.passthrough) metadata.passthrough = {};
return metadata.passthrough;
}
function extractRequestSemantics(requestPayload: unknown): TransformerMetadata {
if (!isRecord(requestPayload)) return {};
const metadata: GeminiRecord = {};
if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
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 metadata: TransformerMetadata = {};
const passthrough = ensurePassthrough(metadata);
if (requestPayload.systemInstruction !== undefined) {
passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
}
if (requestPayload.cachedContent !== undefined) {
passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent);
}
if (requestPayload.safetySettings !== undefined) {
metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings);
}
if (requestPayload.toolConfig !== undefined) {
passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig);
}
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
if (generationConfig) {
@@ -207,7 +225,11 @@ function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
];
for (const key of preservedKeys) {
if (generationConfig[key] !== undefined) {
metadata[key] = cloneJsonValue(generationConfig[key]);
if (key === 'imageConfig') {
metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]);
} else {
passthrough[key] = cloneJsonValue(generationConfig[key]);
}
}
}
}
@@ -226,10 +248,14 @@ function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
.filter((item) => Object.keys(item).length > 0);
if (requestTools.length > 0) {
metadata.tools = requestTools;
passthrough.tools = requestTools;
}
}
if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) {
delete metadata.passthrough;
}
return metadata;
}
@@ -251,9 +277,9 @@ export function serializeGeminiAggregateResponse(
return response;
}
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
export function extractTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata {
const state = ensureAggregateState(payload);
const metadata: GeminiRecord = extractRequestSemantics(requestPayload);
const metadata = extractRequestSemantics(requestPayload);
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
@@ -273,10 +299,15 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno
return metadata;
}
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
return toTransformerMetadataRecord(extractTransformerMetadata(payload, requestPayload)) ?? {};
}
export const geminiGenerateContentOutbound = {
resolveBaseUrl,
resolveModelsUrl,
resolveActionUrl,
extractTransformerMetadata,
extractResponseMetadata,
serializeAggregateResponse: serializeGeminiAggregateResponse,
};
@@ -7,6 +7,7 @@ import type {
OpenAiChatToolCall,
OpenAiChatUsageDetails,
} from './model.js';
import { fromTransformerMetadataRecord } from '../../shared/normalized.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
@@ -345,7 +346,9 @@ function extractCitationsFromPayload(
citations.add(citation);
}
const transformerMetadata = asRecord(payload.transformer_metadata) ?? asRecord(payload.transformerMetadata);
const transformerMetadata = fromTransformerMetadataRecord(
payload.transformer_metadata ?? payload.transformerMetadata,
);
if (transformerMetadata) {
for (const citation of dedupeCitations(transformerMetadata.citations)) {
citations.add(citation);
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createClaudeDownstreamContext } from '../../shared/normalized.js';
import { toTransformerMetadataRecord } from '../../shared/normalized.js';
import { openAiChatTransformer } from './index.js';
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
@@ -154,6 +155,32 @@ describe('openAiChatTransformer.outbound', () => {
});
});
it('consumes shared transformer metadata citations on openai-compatible final responses', () => {
const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-shared-metadata',
model: 'gpt-5',
created: 123,
transformer_metadata: toTransformerMetadataRecord({
citations: [{ uri: 'https://shared.example/citation' }],
thoughtSignature: 'sig-final',
thoughtSignatures: ['sig-tool', 'sig-final'],
passthrough: {
cachedContent: 'cached/item-1',
},
}),
choices: [{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
},
}],
}, 'gpt-5');
expect((normalized as any).citations).toEqual(['https://shared.example/citation']);
});
it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => {
const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-multi',
@@ -81,7 +81,31 @@ describe('sanitizeResponsesBodyForProxy', () => {
});
});
it('defaults encrypted reasoning include when reasoning is requested without an explicit include list', () => {
it('defaults encrypted reasoning include when reasoning is requested without an explicit include list on the codex responses surface', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
summary: 'auto',
},
},
'gpt-5',
false,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
reasoning: {
effort: 'high',
summary: 'auto',
},
});
});
it('does not inject default include on generic responses requests when the codex surface default is disabled', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
@@ -95,8 +119,68 @@ describe('sanitizeResponsesBodyForProxy', () => {
false,
);
expect(result.include).toBeUndefined();
});
it('defaults encrypted reasoning include on codex responses requests even without explicit reasoning config', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
},
'gpt-5',
false,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
});
});
it('respects an explicit empty include list when the codex responses default is enabled', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
summary: 'auto',
},
include: [],
},
'gpt-5',
false,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: [],
reasoning: {
effort: 'high',
summary: 'auto',
},
});
});
it('respects an explicit custom include list when the codex responses default is enabled', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
summary: 'auto',
},
include: ['message.input_image.image_url'],
},
'gpt-5',
false,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['message.input_image.image_url'],
reasoning: {
effort: 'high',
summary: 'auto',
@@ -296,6 +380,19 @@ describe('convertOpenAiBodyToResponsesBody', () => {
});
});
it('does not inject default include when converting non-responses OpenAI input into Responses bodies', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [{ role: 'user', content: 'hello' }],
},
'gpt-5',
true,
);
expect(result.include).toBeUndefined();
});
it('maps OpenAI file-style content blocks into Responses input_file blocks', () => {
const result = convertOpenAiBodyToResponsesBody(
{
@@ -557,7 +654,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
});
});
it('adds encrypted reasoning include for OpenAI-compatible fallback when reasoning options are present', () => {
it('does not inject default include for generic OpenAI-compatible fallback when reasoning options are present', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
@@ -570,6 +667,23 @@ describe('convertResponsesBodyToOpenAiBody', () => {
true,
);
expect(result.include).toBeUndefined();
});
it('adds encrypted reasoning include for OpenAI-compatible fallback when the codex surface default is enabled', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
},
'gpt-5',
true,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
reasoning: {
@@ -578,6 +692,81 @@ describe('convertResponsesBodyToOpenAiBody', () => {
});
});
it('does not inject default include for generic OpenAI-compatible fallback when responses input omits include and reasoning', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
},
'gpt-5',
true,
);
expect(result.include).toBeUndefined();
});
it('adds encrypted reasoning include for OpenAI-compatible fallback when the codex surface default is enabled even without explicit reasoning config', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
},
'gpt-5',
true,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
});
});
it('respects an explicit empty include list for OpenAI-compatible fallback when the codex surface default is enabled', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
include: [],
},
'gpt-5',
true,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: [],
reasoning: {
effort: 'high',
},
});
});
it('respects an explicit custom include list for OpenAI-compatible fallback when the codex surface default is enabled', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
include: ['message.input_image.image_url'],
},
'gpt-5',
true,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['message.input_image.image_url'],
reasoning: {
effort: 'high',
},
});
});
it('keeps Responses input_file items when converting back to OpenAI-compatible bodies', () => {
const result = convertResponsesBodyToOpenAiBody(
{
@@ -82,30 +82,17 @@ function normalizeIncludeList(value: unknown): unknown {
.filter((item) => item.length > 0);
}
function hasReasoningRequest(value: unknown): boolean {
if (!isRecord(value)) return false;
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
return relevantKeys.some((key) => {
const entry = value[key];
if (typeof entry === 'string') return entry.trim().length > 0;
return entry !== undefined && entry !== null;
});
function hasExplicitInclude(body: Record<string, unknown>): boolean {
return Object.prototype.hasOwnProperty.call(body, 'include');
}
function ensureEncryptedReasoningInclude(body: Record<string, unknown>): void {
if (!hasReasoningRequest(body.reasoning)) return;
const normalizedInclude = normalizeIncludeList(body.include);
const includeList = Array.isArray(normalizedInclude) ? normalizedInclude : [];
const alreadyRequested = includeList.some((item) => (
typeof item === 'string' && item.trim().toLowerCase() === 'reasoning.encrypted_content'
));
if (alreadyRequested) {
body.include = includeList;
function applyDefaultResponsesInclude(body: Record<string, unknown>): void {
if (hasExplicitInclude(body)) {
body.include = normalizeIncludeList(body.include);
return;
}
body.include = [...includeList, 'reasoning.encrypted_content'];
body.include = ['reasoning.encrypted_content'];
}
function normalizeTextConfig(
@@ -137,7 +124,10 @@ function normalizeStreamOptions(rawStreamOptions: unknown): unknown {
function normalizeResponsesRequestFieldParity(
body: Record<string, unknown>,
options?: { verbositySource?: unknown },
options?: {
verbositySource?: unknown;
defaultEncryptedReasoningInclude?: boolean;
},
): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...body };
@@ -174,7 +164,9 @@ function normalizeResponsesRequestFieldParity(
if (normalized.include !== undefined) {
normalized.include = normalizeIncludeList(normalized.include);
}
ensureEncryptedReasoningInclude(normalized);
if (options?.defaultEncryptedReasoningInclude) {
applyDefaultResponsesInclude(normalized);
}
if (normalized.stream_options !== undefined) {
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
@@ -376,6 +368,7 @@ export function sanitizeResponsesBodyForProxy(
body: Record<string, unknown>,
modelName: string,
stream: boolean,
options?: { defaultEncryptedReasoningInclude?: boolean },
): Record<string, unknown> {
let normalized = normalizeResponsesBodyForCompatibility({
...body,
@@ -401,6 +394,7 @@ export function sanitizeResponsesBodyForProxy(
normalized = normalizeResponsesRequestFieldParity(normalized, {
verbositySource: body.verbosity,
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
});
const sanitized: Record<string, unknown> = {};
@@ -717,8 +711,11 @@ export function convertResponsesBodyToOpenAiBody(
body: Record<string, unknown>,
modelName: string,
stream: boolean,
options?: { defaultEncryptedReasoningInclude?: boolean },
): Record<string, unknown> {
const normalizedBody = normalizeResponsesRequestFieldParity(body);
const normalizedBody = normalizeResponsesRequestFieldParity(body, {
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
});
const messages: Array<Record<string, unknown>> = [];
const input = normalizedBody.input;
let functionCallIndex = 0;
@@ -2,11 +2,14 @@ import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
fromTransformerMetadataRecord,
normalizeStopReason,
normalizeUpstreamFinalResponse,
pullSseEventsWithDone,
serializeFinalResponse,
toTransformerMetadataRecord,
type NormalizedFinalResponse,
type TransformerMetadata,
} from './normalized.js';
describe('shared normalized helpers', () => {
@@ -143,6 +146,27 @@ describe('shared normalized helpers', () => {
});
});
it('round-trips shared transformer metadata through transport-safe records', () => {
const metadata: TransformerMetadata = {
promptCacheKey: 'cache-key',
truncation: 'auto',
serviceTier: 'priority',
citations: [{ uri: 'https://example.com/citation' }],
thoughtSignature: 'sig-final',
thoughtSignatures: ['sig-tool', 'sig-final'],
geminiSafetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }],
geminiImageConfig: { aspectRatio: '16:9' },
groundingMetadata: [{ webSearchQueries: ['cats'] }],
usageMetadata: { totalTokenCount: 42 },
passthrough: {
cachedContent: 'cached/item-1',
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
},
};
expect(fromTransformerMetadataRecord(toTransformerMetadataRecord(metadata))).toEqual(metadata);
});
it('normalizes known stop reasons', () => {
expect(normalizeStopReason('max_output_tokens')).toBe('length');
expect(normalizeStopReason('tool_use')).toBe('tool_calls');
@@ -78,6 +78,8 @@ export type TransformerMetadata = {
includeObfuscation?: boolean | null;
citations?: unknown;
annotations?: unknown;
groundingMetadata?: unknown;
usageMetadata?: unknown;
geminiSafetySettings?: unknown;
geminiImageConfig?: unknown;
thoughtSignature?: string | null;
@@ -147,3 +149,133 @@ export function mergeNormalizedUsage(
rejectedPredictionTokens: (merged.rejectedPredictionTokens || 0) + (incoming.rejectedPredictionTokens || 0),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
const TRANSFORMER_METADATA_PASSTHROUGH_KEYS = new Set([
'systemInstruction',
'cachedContent',
'toolConfig',
'tools',
'stopSequences',
'responseModalities',
'responseMimeType',
'responseSchema',
'candidateCount',
'maxOutputTokens',
'temperature',
'topP',
'topK',
'presencePenalty',
'frequencyPenalty',
'seed',
'responseLogprobs',
'logprobs',
'thinkingConfig',
]);
export function toTransformerMetadataRecord(
metadata: TransformerMetadata | undefined,
): Record<string, unknown> | undefined {
if (!metadata) return undefined;
const record: Record<string, unknown> = {};
if (metadata.include !== undefined) record.include = cloneJsonValue(metadata.include);
if (metadata.maxToolCalls !== undefined) record.maxToolCalls = metadata.maxToolCalls;
if (metadata.promptCacheKey !== undefined) record.promptCacheKey = cloneJsonValue(metadata.promptCacheKey);
if (metadata.promptCacheRetention !== undefined) {
record.promptCacheRetention = cloneJsonValue(metadata.promptCacheRetention);
}
if (metadata.truncation !== undefined) record.truncation = cloneJsonValue(metadata.truncation);
if (metadata.serviceTier !== undefined) record.serviceTier = cloneJsonValue(metadata.serviceTier);
if (metadata.includeObfuscation !== undefined) record.includeObfuscation = metadata.includeObfuscation;
if (metadata.citations !== undefined) record.citations = cloneJsonValue(metadata.citations);
if (metadata.annotations !== undefined) record.annotations = cloneJsonValue(metadata.annotations);
if (metadata.groundingMetadata !== undefined) {
record.groundingMetadata = cloneJsonValue(metadata.groundingMetadata);
}
if (metadata.usageMetadata !== undefined) {
record.usageMetadata = cloneJsonValue(metadata.usageMetadata);
}
if (metadata.geminiSafetySettings !== undefined) {
record.safetySettings = cloneJsonValue(metadata.geminiSafetySettings);
}
if (metadata.geminiImageConfig !== undefined) {
record.imageConfig = cloneJsonValue(metadata.geminiImageConfig);
}
if (metadata.thoughtSignature !== undefined) record.thoughtSignature = metadata.thoughtSignature;
if (metadata.thoughtSignatures !== undefined) {
record.thoughtSignatures = cloneJsonValue(metadata.thoughtSignatures);
}
if (isRecord(metadata.passthrough)) {
for (const [key, value] of Object.entries(metadata.passthrough)) {
if (record[key] === undefined) {
record[key] = cloneJsonValue(value);
}
}
}
return Object.keys(record).length > 0 ? record : undefined;
}
export function fromTransformerMetadataRecord(
value: unknown,
): TransformerMetadata | undefined {
if (!isRecord(value)) return undefined;
const metadata: TransformerMetadata = {};
if (value.include !== undefined) metadata.include = cloneJsonValue(value.include);
if (typeof value.maxToolCalls === 'number' && Number.isFinite(value.maxToolCalls)) {
metadata.maxToolCalls = value.maxToolCalls;
}
if (value.promptCacheKey !== undefined) metadata.promptCacheKey = cloneJsonValue(value.promptCacheKey);
if (value.promptCacheRetention !== undefined) {
metadata.promptCacheRetention = cloneJsonValue(value.promptCacheRetention);
}
if (value.truncation !== undefined) metadata.truncation = cloneJsonValue(value.truncation);
if (value.serviceTier !== undefined) metadata.serviceTier = cloneJsonValue(value.serviceTier);
if (typeof value.includeObfuscation === 'boolean') metadata.includeObfuscation = value.includeObfuscation;
if (value.citations !== undefined) metadata.citations = cloneJsonValue(value.citations);
if (value.annotations !== undefined) metadata.annotations = cloneJsonValue(value.annotations);
if (value.groundingMetadata !== undefined) {
metadata.groundingMetadata = cloneJsonValue(value.groundingMetadata);
}
if (value.usageMetadata !== undefined) {
metadata.usageMetadata = cloneJsonValue(value.usageMetadata);
}
if (value.safetySettings !== undefined) {
metadata.geminiSafetySettings = cloneJsonValue(value.safetySettings);
}
if (value.imageConfig !== undefined) {
metadata.geminiImageConfig = cloneJsonValue(value.imageConfig);
}
if (typeof value.thoughtSignature === 'string') metadata.thoughtSignature = value.thoughtSignature;
if (Array.isArray(value.thoughtSignatures)) {
metadata.thoughtSignatures = value.thoughtSignatures
.filter((item): item is string => typeof item === 'string');
}
const passthroughEntries = Object.entries(value)
.filter(([key]) => TRANSFORMER_METADATA_PASSTHROUGH_KEYS.has(key));
if (passthroughEntries.length > 0) {
metadata.passthrough = Object.fromEntries(
passthroughEntries.map(([key, entry]) => [key, cloneJsonValue(entry)]),
);
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}