feat: Implement compatibility strategies for responses and chat endpoints
- Added `routeCompatibility.ts` to handle response compatibility for different endpoints. - Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic. - Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`. - Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints. - Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`. - Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests. - Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
OpenAiChatUsageDetails,
|
||||
} from './model.js';
|
||||
import { fromTransformerMetadataRecord } from '../../shared/normalized.js';
|
||||
import { extractInlineThinkTags } from '../../shared/thinkTagParser.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -98,15 +99,23 @@ function extractTextPart(value: unknown): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function joinNonEmpty(parts: string[]): string {
|
||||
return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n');
|
||||
}
|
||||
|
||||
function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } {
|
||||
if (typeof value === 'string') return { content: value, reasoning: '' };
|
||||
if (typeof value === 'string') return extractInlineThinkTags(value);
|
||||
if (Array.isArray(value)) {
|
||||
const contentParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) {
|
||||
if (typeof item === 'string') contentParts.push(item);
|
||||
if (typeof item === 'string') {
|
||||
const parsed = extractInlineThinkTags(item);
|
||||
if (parsed.content) contentParts.push(parsed.content);
|
||||
if (parsed.reasoning) reasoningParts.push(parsed.reasoning);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -119,8 +128,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
|
||||
reasoningParts.push(item.reasoning);
|
||||
continue;
|
||||
}
|
||||
const text = extractTextPart(item);
|
||||
if (text) contentParts.push(text);
|
||||
const parsed = extractInlineThinkTags(extractTextPart(item));
|
||||
if (parsed.content) contentParts.push(parsed.content);
|
||||
if (parsed.reasoning) reasoningParts.push(parsed.reasoning);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -130,13 +140,14 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return { content: '', reasoning: '' };
|
||||
const parsed = extractInlineThinkTags(extractTextPart(value.content ?? value));
|
||||
return {
|
||||
content: extractTextPart(value.content ?? value),
|
||||
reasoning: typeof value.reasoning_content === 'string'
|
||||
? value.reasoning_content
|
||||
: typeof value.reasoning === 'string'
|
||||
? value.reasoning
|
||||
: '',
|
||||
content: parsed.content,
|
||||
reasoning: joinNonEmpty([
|
||||
typeof value.reasoning_content === 'string' ? value.reasoning_content : '',
|
||||
typeof value.reasoning === 'string' ? value.reasoning : '',
|
||||
parsed.reasoning,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { parseDownstreamChatRequest } from '../../shared/normalized.js';
|
||||
import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js';
|
||||
import { extractChatRequestMetadata } from './helpers.js';
|
||||
import type { OpenAiChatParsedRequest } from './model.js';
|
||||
import type { OpenAiChatParsedRequest, OpenAiChatRequestEnvelope } from './model.js';
|
||||
|
||||
export const openAiChatInbound = {
|
||||
parse(body: unknown): { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown } } {
|
||||
parse(body: unknown): { value?: OpenAiChatRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = parseDownstreamChatRequest(body, 'openai') as {
|
||||
value?: OpenAiChatParsedRequest;
|
||||
error?: { statusCode: number; payload: unknown };
|
||||
};
|
||||
if (!parsed.value) return parsed;
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid chat request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
parsed.value = {
|
||||
...parsed.value,
|
||||
requestMetadata: extractChatRequestMetadata(body),
|
||||
const metadata = extractChatRequestMetadata(body);
|
||||
return {
|
||||
value: createProtocolRequestEnvelope({
|
||||
protocol: 'openai/chat',
|
||||
model: parsed.value.requestedModel,
|
||||
stream: parsed.value.isStream,
|
||||
rawBody: body,
|
||||
parsed: parsed.value,
|
||||
...(metadata ? { metadata } : {}),
|
||||
}),
|
||||
};
|
||||
return parsed;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,7 +31,16 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.upstreamBody).toMatchObject({
|
||||
expect(result.value).toMatchObject({
|
||||
protocol: 'openai/chat',
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
rawBody: {
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
});
|
||||
expect(result.value?.parsed.upstreamBody).toMatchObject({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'mp3' },
|
||||
reasoning_effort: 'high',
|
||||
@@ -46,7 +55,7 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
response_format: { type: 'json_object' },
|
||||
stream_options: { include_usage: true },
|
||||
});
|
||||
expect((result.value as any)?.requestMetadata).toEqual({
|
||||
expect((result.value as any)?.metadata).toEqual({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'mp3' },
|
||||
reasoningEffort: 'high',
|
||||
@@ -82,14 +91,19 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.upstreamBody).toMatchObject({
|
||||
expect(result.value).toMatchObject({
|
||||
protocol: 'openai/chat',
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
});
|
||||
expect(result.value?.parsed.upstreamBody).toMatchObject({
|
||||
modalities: ['text', 42, 'audio', '', null],
|
||||
reasoning_budget: '2048',
|
||||
top_logprobs: '5',
|
||||
logit_bias: { '42': '7', invalid: 'oops' },
|
||||
stream_options: { include_usage: 1 },
|
||||
});
|
||||
expect((result.value as any)?.requestMetadata).toEqual({
|
||||
expect((result.value as any)?.metadata).toEqual({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'wav' },
|
||||
reasoningEffort: 'medium',
|
||||
@@ -107,6 +121,28 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.outbound', () => {
|
||||
it('normalizes inline think tags in final chat responses', () => {
|
||||
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'chatcmpl-inline-think',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '<think>plan quietly</think>visible answer',
|
||||
},
|
||||
}],
|
||||
}, 'gpt-5');
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
content: 'visible answer',
|
||||
reasoningContent: 'plan quietly',
|
||||
finishReason: 'stop',
|
||||
});
|
||||
});
|
||||
|
||||
it('carries annotations, citations, and detailed usage through final serialization', () => {
|
||||
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'chatcmpl-1',
|
||||
@@ -402,6 +438,53 @@ describe('openAiChatTransformer.stream', () => {
|
||||
'https://b.example',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes inline think tags inside multi-choice stream chunks', () => {
|
||||
const context = openAiChatTransformer.createStreamContext('gpt-5');
|
||||
const event = openAiChatTransformer.transformStreamEvent({
|
||||
id: 'chatcmpl-stream-multi-think',
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: '<think>plan-0</think>choice-0',
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: '<think>plan-1</think>choice-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}, context, 'gpt-5');
|
||||
|
||||
const payloads = parseSsePayloads(
|
||||
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
|
||||
);
|
||||
|
||||
expect((payloads[0] as any).choices[0]).toMatchObject({
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'plan-0',
|
||||
},
|
||||
});
|
||||
expect((payloads[0] as any).choices[1]).toMatchObject({
|
||||
index: 1,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-1',
|
||||
reasoning_content: 'plan-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.aggregator', () => {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
|
||||
import { openAiChatInbound } from './inbound.js';
|
||||
import { openAiChatOutbound } from './outbound.js';
|
||||
import { createChatProxyStreamSession } from './proxyStream.js';
|
||||
import { openAiChatStream } from './stream.js';
|
||||
import { openAiChatUsage } from './usage.js';
|
||||
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
||||
import type { OpenAiChatParsedRequest as OpenAiChatParsedRequestModel } from './model.js';
|
||||
import type {
|
||||
OpenAiChatParsedRequest as OpenAiChatParsedRequestModel,
|
||||
OpenAiChatRequestEnvelope as OpenAiChatRequestEnvelopeModel,
|
||||
} from './model.js';
|
||||
|
||||
export const openAiChatTransformer = {
|
||||
protocol: 'openai/chat' as const,
|
||||
@@ -12,11 +17,17 @@ export const openAiChatTransformer = {
|
||||
outbound: openAiChatOutbound,
|
||||
stream: openAiChatStream,
|
||||
usage: openAiChatUsage,
|
||||
compatibility: {
|
||||
createEndpointStrategy: createChatEndpointStrategy,
|
||||
},
|
||||
aggregator: {
|
||||
createState: createOpenAiChatAggregateState,
|
||||
applyEvent: applyOpenAiChatStreamEvent,
|
||||
finalize: finalizeOpenAiChatAggregate,
|
||||
},
|
||||
proxyStream: {
|
||||
createSession: createChatProxyStreamSession,
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
|
||||
return openAiChatInbound.parse(body);
|
||||
},
|
||||
@@ -72,3 +83,4 @@ export const openAiChatTransformer = {
|
||||
|
||||
export type OpenAiChatTransformer = typeof openAiChatTransformer;
|
||||
export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel;
|
||||
export type OpenAiChatRequestEnvelope = OpenAiChatRequestEnvelopeModel;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
NormalizedStreamEvent,
|
||||
ParsedDownstreamChatRequest,
|
||||
} from '../../shared/normalized.js';
|
||||
import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
|
||||
|
||||
export type OpenAiChatAudioRequest = {
|
||||
format?: string;
|
||||
@@ -31,9 +32,12 @@ export type OpenAiChatUsageDetails = {
|
||||
completion_tokens_details?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & {
|
||||
requestMetadata?: OpenAiChatRequestMetadata;
|
||||
};
|
||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
|
||||
export type OpenAiChatRequestEnvelope = ProtocolRequestEnvelope<
|
||||
'openai/chat',
|
||||
OpenAiChatParsedRequest,
|
||||
OpenAiChatRequestMetadata
|
||||
>;
|
||||
|
||||
export type OpenAiChatToolCall = {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { anthropicMessagesTransformer } from '../../anthropic/messages/index.js';
|
||||
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
|
||||
import { type DownstreamFormat, type ParsedSseEvent } from '../../shared/normalized.js';
|
||||
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
||||
import { openAiChatOutbound } from './outbound.js';
|
||||
import { openAiChatStream } from './stream.js';
|
||||
|
||||
type StreamReader = {
|
||||
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
||||
cancel(reason?: unknown): Promise<unknown>;
|
||||
releaseLock(): void;
|
||||
};
|
||||
|
||||
type ChatProxyStreamSessionInput = {
|
||||
downstreamFormat: DownstreamFormat;
|
||||
modelName: string;
|
||||
onParsedPayload?: (payload: unknown) => void;
|
||||
writeLines: (lines: string[]) => void;
|
||||
writeRaw: (chunk: string) => void;
|
||||
};
|
||||
|
||||
type ResponseSink = {
|
||||
end(): void;
|
||||
};
|
||||
|
||||
export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) {
|
||||
const downstreamTransformer = input.downstreamFormat === 'claude'
|
||||
? anthropicMessagesTransformer
|
||||
: {
|
||||
createStreamContext: openAiChatStream.createContext,
|
||||
transformStreamEvent: openAiChatStream.normalizeEvent,
|
||||
serializeStreamEvent: openAiChatStream.serializeEvent,
|
||||
serializeDone: openAiChatStream.serializeDone,
|
||||
pullSseEvents: openAiChatStream.pullSseEvents,
|
||||
};
|
||||
const streamContext = downstreamTransformer.createStreamContext(input.modelName);
|
||||
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||
const chatAggregateState = input.downstreamFormat === 'openai'
|
||||
? createOpenAiChatAggregateState()
|
||||
: null;
|
||||
let finalized = false;
|
||||
|
||||
const finalize = () => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
|
||||
// For native Anthropic streams, EOF without message_stop is not a clean
|
||||
// completion. Forward the partial stream as-is instead of fabricating an
|
||||
// end_turn/message_stop pair that makes clients think the run finished.
|
||||
if (input.downstreamFormat === 'claude' && !claudeContext.doneSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.downstreamFormat === 'openai' && chatAggregateState && chatAggregateState.choices.size > 0) {
|
||||
const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values())
|
||||
.some((choice) => !choice.finishReason);
|
||||
if (needsTerminalFinishChunk) {
|
||||
const terminalChunk = openAiChatOutbound.buildSyntheticChunks(
|
||||
finalizeOpenAiChatAggregate(chatAggregateState, {
|
||||
id: streamContext.id,
|
||||
model: streamContext.model,
|
||||
created: streamContext.created,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
}),
|
||||
).slice(-1)[0];
|
||||
if (terminalChunk) {
|
||||
input.writeLines([`data: ${JSON.stringify(terminalChunk)}\n\n`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input.writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
|
||||
};
|
||||
|
||||
const handleEventBlock = async (eventBlock: ParsedSseEvent): Promise<boolean> => {
|
||||
if (eventBlock.data === '[DONE]') {
|
||||
finalize();
|
||||
return true;
|
||||
}
|
||||
|
||||
let parsedPayload: unknown = null;
|
||||
if (input.downstreamFormat === 'claude') {
|
||||
const consumed = anthropicMessagesTransformer.consumeSseEventBlock(
|
||||
eventBlock,
|
||||
streamContext,
|
||||
claudeContext,
|
||||
input.modelName,
|
||||
);
|
||||
parsedPayload = consumed.parsedPayload;
|
||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||
input.onParsedPayload?.(parsedPayload);
|
||||
}
|
||||
if (consumed.handled) {
|
||||
input.writeLines(consumed.lines);
|
||||
return consumed.done;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
parsedPayload = JSON.parse(eventBlock.data);
|
||||
} catch {
|
||||
parsedPayload = null;
|
||||
}
|
||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||
input.onParsedPayload?.(parsedPayload);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName);
|
||||
if (input.downstreamFormat === 'openai' && chatAggregateState) {
|
||||
applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent);
|
||||
}
|
||||
input.writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
|
||||
return input.downstreamFormat === 'claude' && claudeContext.doneSent;
|
||||
}
|
||||
|
||||
if (input.downstreamFormat === 'openai') {
|
||||
input.writeRaw(`data: ${eventBlock.data}\n\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
input.writeLines(anthropicMessagesTransformer.serializeStreamEvent({
|
||||
contentDelta: eventBlock.data,
|
||||
}, streamContext, claudeContext));
|
||||
return claudeContext.doneSent;
|
||||
};
|
||||
|
||||
return {
|
||||
consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
input.onParsedPayload?.(payload);
|
||||
}
|
||||
if (input.downstreamFormat === 'openai') {
|
||||
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText);
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
input.writeLines(
|
||||
openAiChatOutbound
|
||||
.buildSyntheticChunks(normalizedFinal)
|
||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`),
|
||||
);
|
||||
} else {
|
||||
input.writeLines(
|
||||
anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
|
||||
payload,
|
||||
input.modelName,
|
||||
fallbackText,
|
||||
streamContext,
|
||||
claudeContext,
|
||||
),
|
||||
);
|
||||
}
|
||||
finalize();
|
||||
response?.end();
|
||||
},
|
||||
async run(reader: StreamReader | null | undefined, response: ResponseSink) {
|
||||
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
|
||||
reader,
|
||||
response,
|
||||
pullEvents: (buffer) => downstreamTransformer.pullSseEvents(buffer),
|
||||
handleEvent: handleEventBlock,
|
||||
onEof: finalize,
|
||||
});
|
||||
await lifecycle.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,23 +36,35 @@ export const openAiChatStream = {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
|
||||
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName);
|
||||
const choiceEvents = extractChatChoiceEvents(payload);
|
||||
const primaryChoice = choiceEvents[0];
|
||||
const normalizedChoiceEvents = choiceEvents.map((choiceEvent, index) => (
|
||||
index === 0
|
||||
? {
|
||||
...choiceEvent,
|
||||
role: normalized.role !== undefined ? normalized.role : choiceEvent.role,
|
||||
contentDelta: normalized.contentDelta,
|
||||
reasoningDelta: normalized.reasoningDelta,
|
||||
toolCallDeltas: normalized.toolCallDeltas !== undefined
|
||||
? normalized.toolCallDeltas
|
||||
: choiceEvent.toolCallDeltas,
|
||||
finishReason: normalized.finishReason !== undefined
|
||||
? normalized.finishReason
|
||||
: choiceEvent.finishReason,
|
||||
}
|
||||
: choiceEvent
|
||||
));
|
||||
return {
|
||||
...normalizeUpstreamStreamEvent(payload, context, modelName),
|
||||
...normalized,
|
||||
...(primaryChoice
|
||||
? {
|
||||
choiceIndex: primaryChoice.index,
|
||||
role: primaryChoice.role,
|
||||
contentDelta: primaryChoice.contentDelta,
|
||||
reasoningDelta: primaryChoice.reasoningDelta,
|
||||
toolCallDeltas: primaryChoice.toolCallDeltas,
|
||||
finishReason: primaryChoice.finishReason,
|
||||
annotations: primaryChoice.annotations,
|
||||
citations: primaryChoice.citations,
|
||||
}
|
||||
: {}),
|
||||
...(choiceEvents.length > 0 ? { choiceEvents } : {}),
|
||||
...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user