From 7f352dacf500566882d282d770cf7399d2b9d5f4 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 20 Nov 2025 17:01:20 -0700 Subject: [PATCH] feat: enhance generation metadata handling across providers --- src/api/index.ts | 8 + src/api/providers/gemini.ts | 13 +- src/api/providers/openai-native.ts | 63 ++++-- src/api/providers/openrouter.ts | 21 +- src/api/transform/gemini-format.ts | 12 ++ src/api/transform/openai-format.ts | 19 +- src/core/task/Task.ts | 198 +++++++----------- .../__tests__/reasoning-preservation.test.ts | 25 ++- src/shared/api.ts | 16 ++ 9 files changed, 209 insertions(+), 166 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index 05c7493078..6b0d82c42a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import type { ProviderSettings, ModelInfo, ToolProtocol } from "@roo-code/types" +import type { ProviderMessageMetadata } from "../shared/api" import { ApiStream } from "./transform/stream" @@ -99,6 +100,13 @@ export interface ApiHandler { getModel(): { id: string; info: ModelInfo } + /** + * Retrieves metadata for the last generated response. + * This includes provider-specific reasoning data, thought signatures, and response IDs + * that need to be persisted for conversation continuity. + */ + getGenerationMetadata?(): ProviderMessageMetadata | undefined + /** * Counts tokens for content blocks * All providers extend BaseProvider which provides a default tiktoken implementation, diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 89c816e815..1bc06029ef 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -12,7 +12,7 @@ import type { JWTInput } from "google-auth-library" import { type ModelInfo, type GeminiModelId, geminiDefaultModelId, geminiModels } from "@roo-code/types" -import type { ApiHandlerOptions } from "../../shared/api" +import type { ApiHandlerOptions, ProviderMessageMetadata } from "../../shared/api" import { safeJsonParse } from "../../shared/safeJsonParse" import { convertAnthropicContentToGemini, convertAnthropicMessageToGemini } from "../transform/gemini-format" @@ -437,12 +437,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } } - public getThoughtSignature(): string | undefined { - return this.lastThoughtSignature - } + getGenerationMetadata(): ProviderMessageMetadata | undefined { + if (!this.lastThoughtSignature && !this.lastResponseId) return undefined - public getResponseId(): string | undefined { - return this.lastResponseId + return { + geminiThoughtSignature: this.lastThoughtSignature, + geminiResponseId: this.lastResponseId, + } } public calculateCost({ diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 74ba621c43..d47e972285 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -13,7 +13,7 @@ import { type ServiceTier, } from "@roo-code/types" -import type { ApiHandlerOptions } from "../../shared/api" +import type { ApiHandlerOptions, ProviderMessageMetadata } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -367,7 +367,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Process each message for (const message of messages) { - // Check if this is a reasoning item (already formatted in API history) + // Check if this is a reasoning item (already formatted in API history - legacy support) if ((message as any).type === "reasoning") { // Pass through reasoning items as-is formattedInput.push(message) @@ -421,6 +421,16 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } else if (Array.isArray(message.content)) { for (const block of message.content) { if (block.type === "text") { + // Extract encrypted content from provider metadata if present + const metadata = (block as any).providerMetadata as ProviderMessageMetadata | undefined + if (metadata?.openAiEncryptedContent) { + formattedInput.push({ + type: "reasoning", + encrypted_content: metadata.openAiEncryptedContent, + summary: metadata.openAiReasoningSummary || [], + ...(metadata.openAiResponseId ? { id: metadata.openAiResponseId } : {}), + }) + } content.push({ type: "output_text", text: block.text }) } else if (block.type === "tool_use") { // Map Anthropic tool_use to Responses API function_call item @@ -1256,32 +1266,43 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio return { id: id.startsWith("o3-mini") ? "o3-mini" : id, info, ...params, verbosity: params.verbosity } } - /** - * Extracts encrypted_content and id from the first reasoning item in the output array. - * This is the minimal data needed for stateless API continuity. - * - * @returns Object with encrypted_content and id, or undefined if not available - */ - getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { - if (!this.lastResponseOutput) return undefined + getGenerationMetadata(): ProviderMessageMetadata | undefined { + if (!this.lastResponseOutput && !this.lastResponseId) return undefined - // Find the first reasoning item with encrypted_content - const reasoningItem = this.lastResponseOutput.find( - (item) => item.type === "reasoning" && item.encrypted_content, - ) + let openAiEncryptedContent: string | undefined + let openAiReasoningSummary: any[] | undefined + let openAiResponseId: string | undefined - if (!reasoningItem?.encrypted_content) return undefined + if (this.lastResponseOutput) { + // Find the first reasoning item with encrypted_content + const reasoningItem = this.lastResponseOutput.find( + (item) => item.type === "reasoning" && item.encrypted_content, + ) + if (reasoningItem?.encrypted_content) { + openAiEncryptedContent = reasoningItem.encrypted_content + // Prefer specific reasoning item ID if available, though top-level response ID is usually sufficient + if (reasoningItem.id) { + openAiResponseId = reasoningItem.id + } + // Capture summary if present + if (reasoningItem.summary) { + openAiReasoningSummary = reasoningItem.summary + } + } + } + + // Fallback to top-level response ID if not found in reasoning item + if (!openAiResponseId) { + openAiResponseId = this.lastResponseId + } return { - encrypted_content: reasoningItem.encrypted_content, - ...(reasoningItem.id ? { id: reasoningItem.id } : {}), + openAiEncryptedContent, + openAiReasoningSummary, + openAiResponseId, } } - getResponseId(): string | undefined { - return this.lastResponseId - } - async completePrompt(prompt: string): Promise { // Create AbortController for cancellation this.abortController = new AbortController() diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index d2809c90b5..58363c3502 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -9,7 +9,7 @@ import { DEEP_SEEK_DEFAULT_TEMPERATURE, } from "@roo-code/types" -import type { ApiHandlerOptions, ModelRecord } from "../../shared/api" +import type { ApiHandlerOptions, ModelRecord, ProviderMessageMetadata } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStreamChunk } from "../transform/stream" @@ -87,6 +87,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH protected models: ModelRecord = {} protected endpoints: ModelRecord = {} private readonly providerName = "OpenRouter" + private lastReasoningDetails: any[] = [] constructor(options: ApiHandlerOptions) { super() @@ -193,6 +194,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } + // Reset last reasoning details for this request + this.lastReasoningDetails = [] + let stream try { stream = await this.client.chat.completions.create(completionParams) @@ -263,6 +267,13 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH // OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks if (delta && "reasoning_details" in delta && delta.reasoning_details) { + // Capture for getGenerationMetadata + if (Array.isArray(delta.reasoning_details)) { + this.lastReasoningDetails.push(...delta.reasoning_details) + } else { + this.lastReasoningDetails.push(delta.reasoning_details) + } + yield { type: "reasoning_details", reasoning_details: delta.reasoning_details, @@ -338,6 +349,14 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH return { id, info, topP: isDeepSeekR1 ? 0.95 : undefined, ...params } } + getGenerationMetadata(): ProviderMessageMetadata | undefined { + if (this.lastReasoningDetails.length === 0) return undefined + + return { + openRouterReasoningDetails: this.lastReasoningDetails, + } + } + async completePrompt(prompt: string) { let { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel() diff --git a/src/api/transform/gemini-format.ts b/src/api/transform/gemini-format.ts index ffb8b8f789..32d2faacfc 100644 --- a/src/api/transform/gemini-format.ts +++ b/src/api/transform/gemini-format.ts @@ -1,5 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { Content, Part } from "@google/genai" +import type { ProviderMessageMetadata } from "../../shared/api" type ThoughtSignatureContentBlock = { type: "thoughtSignature" @@ -26,6 +27,17 @@ export function convertAnthropicContentToGemini( const sigBlock = content.find((block) => isThoughtSignatureContentBlock(block)) as ThoughtSignatureContentBlock if (sigBlock?.thoughtSignature) { activeThoughtSignature = sigBlock.thoughtSignature + } else { + // Check for providerMetadata on text blocks + const textBlock = content.find( + (block) => + block.type === "text" && + ((block as any).providerMetadata as ProviderMessageMetadata)?.geminiThoughtSignature, + ) + if (textBlock) { + activeThoughtSignature = ((textBlock as any).providerMetadata as ProviderMessageMetadata) + .geminiThoughtSignature + } } } diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 4980e73aef..9d4aad053c 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -1,5 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +import type { ProviderMessageMetadata } from "../../shared/api" export function convertToOpenAiMessages( anthropicMessages: Anthropic.Messages.MessageParam[], @@ -113,6 +114,7 @@ export function convertToOpenAiMessages( const reasoningDetails: ReasoningDetail[] = [] if (nonToolMessages.length > 0) { nonToolMessages.forEach((part) => { + // Check for legacy reasoning_details on text block if (part.type === "text" && "reasoning_details" in part) { const details = (part as any).reasoning_details if (Array.isArray(details)) { @@ -121,6 +123,13 @@ export function convertToOpenAiMessages( reasoningDetails.push(details) } } + // Check for new providerMetadata on text block + else if (part.type === "text" && (part as any).providerMetadata) { + const metadata = (part as any).providerMetadata as ProviderMessageMetadata + if (metadata?.openRouterReasoningDetails) { + reasoningDetails.push(...metadata.openRouterReasoningDetails) + } + } }) content = nonToolMessages .map((part) => { @@ -173,6 +182,7 @@ type ReasoningDetail = { // https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text" text?: string + summary?: string data?: string // Encrypted reasoning data signature?: string | null id?: string | null // Unique identifier for the reasoning detail @@ -212,6 +222,7 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso // Concatenate all text parts let concatenatedText = "" let hasText = false + let summary: string | undefined let signature: string | undefined let id: string | undefined let format = "unknown" @@ -222,6 +233,9 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso concatenatedText += detail.text hasText = true } + if (detail.summary !== undefined) { + summary = detail.summary + } // Keep the signature from the last item that has one if (detail.signature) { signature = detail.signature @@ -241,10 +255,11 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso // Create consolidated entry for text if any text parts were found // This avoids creating text entries for purely encrypted blocks or metadata-only updates that belong to encrypted blocks - if (hasText) { + if (hasText || summary !== undefined) { const consolidatedEntry: ReasoningDetail = { type: type, - text: concatenatedText, + text: hasText ? concatenatedText : undefined, + summary: summary, signature: signature, id: id, format: format, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 18294ff2ff..020d915da2 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -65,7 +65,7 @@ import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes" import { DiffStrategy, type ToolUse } from "../../shared/tools" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { getModelMaxOutputTokens } from "../../shared/api" +import { getModelMaxOutputTokens, ProviderMessageMetadata } from "../../shared/api" // services import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" @@ -647,84 +647,54 @@ export class Task extends EventEmitter implements TaskLike { } private async addToApiConversationHistory(message: Anthropic.MessageParam, reasoning?: string) { - // Capture the encrypted_content / thought signatures from the provider (e.g., OpenAI Responses API, Google GenAI) if present. + // Capture generation metadata (reasoning, thought signatures, etc.) from the provider if present. // We only persist data reported by the current response body. - const handler = this.api as ApiHandler & { - getResponseId?: () => string | undefined - getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined - getThoughtSignature?: () => string | undefined - getSummary?: () => any[] | undefined + let metadata = this.api.getGenerationMetadata?.() + + if (reasoning) { + metadata = { ...metadata, reasoning } } if (message.role === "assistant") { - const responseId = handler.getResponseId?.() - const reasoningData = handler.getEncryptedContent?.() - const thoughtSignature = handler.getThoughtSignature?.() - const reasoningSummary = handler.getSummary?.() - // Start from the original assistant message const messageWithTs: any = { ...message, - ...(responseId ? { id: responseId } : {}), + ...(metadata?.openAiResponseId || metadata?.geminiResponseId + ? { id: metadata.openAiResponseId || metadata.geminiResponseId } + : {}), ts: Date.now(), } - // Store reasoning: plain text (most providers) or encrypted (OpenAI Native) - if (reasoning) { - const reasoningBlock = { - type: "reasoning", - text: reasoning, - summary: reasoningSummary ?? ([] as any[]), - } - - if (typeof messageWithTs.content === "string") { - messageWithTs.content = [ - reasoningBlock, - { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, - ] - } else if (Array.isArray(messageWithTs.content)) { - messageWithTs.content = [reasoningBlock, ...messageWithTs.content] - } else if (!messageWithTs.content) { - messageWithTs.content = [reasoningBlock] - } - } else if (reasoningData?.encrypted_content) { - // OpenAI Native encrypted reasoning - const reasoningBlock = { - type: "reasoning", - summary: [] as any[], - encrypted_content: reasoningData.encrypted_content, - ...(reasoningData.id ? { id: reasoningData.id } : {}), - } - - if (typeof messageWithTs.content === "string") { - messageWithTs.content = [ - reasoningBlock, - { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, - ] - } else if (Array.isArray(messageWithTs.content)) { - messageWithTs.content = [reasoningBlock, ...messageWithTs.content] - } else if (!messageWithTs.content) { - messageWithTs.content = [reasoningBlock] - } - } - - // If we have a thought signature, append it as a dedicated content block - // so it can be round-tripped in api_history.json and re-sent on subsequent calls. - if (thoughtSignature) { - const thoughtSignatureBlock = { - type: "thoughtSignature", - thoughtSignature, - } - + // If we have metadata, attach it to the text block(s) of the assistant message. + // This standardized approach avoids creating separate "virtual" blocks in the history. + if (metadata) { + // Normalize content to array for easier processing if (typeof messageWithTs.content === "string") { messageWithTs.content = [ { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, - thoughtSignatureBlock, ] - } else if (Array.isArray(messageWithTs.content)) { - messageWithTs.content = [...messageWithTs.content, thoughtSignatureBlock] - } else if (!messageWithTs.content) { - messageWithTs.content = [thoughtSignatureBlock] + } + + if (Array.isArray(messageWithTs.content)) { + // Attach metadata to the first text block we find, or create one if needed + let attached = false + for (const block of messageWithTs.content) { + if (block.type === "text") { + block.providerMetadata = metadata + attached = true + break + } + } + + // If no text block existed (e.g. tool use only) but we have metadata to persist, + // create an empty text block to hold it. + if (!attached) { + messageWithTs.content.unshift({ + type: "text", + text: "", // Empty text block just to hold metadata + providerMetadata: metadata, + }) + } } } @@ -1309,11 +1279,7 @@ export class Task extends EventEmitter implements TaskLike { } else { // This is a new partial message, so add it with partial state. const sayTs = Date.now() - - if (!options.isNonInteractive) { - this.lastMessageTs = sayTs - } - + this.lastMessageTs = sayTs await this.addToClineMessages({ ts: sayTs, type: "say", @@ -2118,14 +2084,14 @@ export class Task extends EventEmitter implements TaskLike { outputTokens, cacheWriteTokens, cacheReadTokens, - ) + ) : calculateApiCostOpenAI( streamModelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, - ) + ) this.clineMessages[lastApiReqIndex].text = JSON.stringify({ ...existingData, @@ -2554,7 +2520,7 @@ export class Task extends EventEmitter implements TaskLike { currentItem.retryAttempt ?? 0, error, streamingFailedMessage, - ) + ) // Check if task was aborted during the backoff if (this.abort) { @@ -3380,6 +3346,13 @@ export class Task extends EventEmitter implements TaskLike { ): Array< Anthropic.Messages.MessageParam | { type: "reasoning"; encrypted_content: string; id?: string; summary?: any[] } > { + // The clean history sent to the provider. + // Note: This type definition is a union because OpenAI Native (Responses API) supports a separate + // { type: "reasoning" } item for stateless continuity of encrypted reasoning. Other providers + // generally just expect standard Anthropic.Messages.MessageParam objects. + // The standardized `providerMetadata` approach attaches reasoning data to text blocks, which + // providers can then extract and format as needed (e.g. OpenAI Native provider extracts it back + // into a separate item in its `formatFullConversation` method). type ReasoningItemForRequest = { type: "reasoning" encrypted_content: string @@ -3390,43 +3363,28 @@ export class Task extends EventEmitter implements TaskLike { const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = [] for (const msg of messages) { - // Standalone reasoning: send encrypted, skip plain text - if (msg.type === "reasoning") { - if (msg.encrypted_content) { - cleanConversationHistory.push({ - type: "reasoning", - summary: msg.summary, - encrypted_content: msg.encrypted_content!, - ...(msg.id ? { id: msg.id } : {}), - }) - } + // Legacy support: Handle standalone reasoning items from older history + if (msg.type === "reasoning" && msg.encrypted_content) { + cleanConversationHistory.push({ + type: "reasoning", + summary: msg.summary || [], + encrypted_content: msg.encrypted_content!, + ...(msg.id ? { id: msg.id } : {}), + }) continue } - // Preferred path: assistant message with embedded reasoning as first content block - if (msg.role === "assistant") { - const rawContent = msg.content - - const contentArray: Anthropic.Messages.ContentBlockParam[] = Array.isArray(rawContent) - ? (rawContent as Anthropic.Messages.ContentBlockParam[]) - : rawContent !== undefined - ? ([ - { type: "text", text: rawContent } satisfies Anthropic.Messages.TextBlockParam, - ] as Anthropic.Messages.ContentBlockParam[]) - : [] - - const [first, ...rest] = contentArray - - // Embedded reasoning: encrypted (send) or plain text (skip) - const hasEncryptedReasoning = - first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string" - const hasPlainTextReasoning = - first && (first as any).type === "reasoning" && typeof (first as any).text === "string" - - if (hasEncryptedReasoning) { - const reasoningBlock = first as any - - // Send as separate reasoning item (OpenAI Native) + // Legacy support: Handle embedded reasoning blocks from the transitional period + // (where reasoning was a block inside content with type="reasoning") + if (msg.role === "assistant" && Array.isArray(msg.content)) { + const firstBlock = msg.content[0] + if ( + firstBlock && + (firstBlock as any).type === "reasoning" && + typeof (firstBlock as any).encrypted_content === "string" + ) { + // Found legacy embedded reasoning block - emit as separate item + const reasoningBlock = firstBlock as any cleanConversationHistory.push({ type: "reasoning", summary: reasoningBlock.summary ?? [], @@ -3434,22 +3392,14 @@ export class Task extends EventEmitter implements TaskLike { ...(reasoningBlock.id ? { id: reasoningBlock.id } : {}), }) - // Send assistant message without reasoning - let assistantContent: Anthropic.Messages.MessageParam["content"] - - if (rest.length === 0) { - assistantContent = "" - } else if (rest.length === 1 && rest[0].type === "text") { - assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text - } else { - assistantContent = rest + // Add the rest of the message as the assistant message + const restOfContent = msg.content.slice(1) + if (restOfContent.length > 0) { + cleanConversationHistory.push({ + role: "assistant", + content: restOfContent as Anthropic.Messages.ContentBlockParam[], + }) } - - cleanConversationHistory.push({ - role: "assistant", - content: assistantContent, - } satisfies Anthropic.Messages.MessageParam) - continue } else if (hasPlainTextReasoning) { // Check if the model's preserveReasoning flag is set @@ -3481,7 +3431,9 @@ export class Task extends EventEmitter implements TaskLike { } } - // Default path for regular messages (no embedded reasoning) + // Standard path: Pass the message through. + // Metadata attached to text blocks (providerMetadata) is preserved in the object + // and will be handled by the specific provider's formatting logic. if (msg.role) { cleanConversationHistory.push({ role: msg.role, diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 7a73d2b1d0..cdd6f262dc 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -326,7 +326,7 @@ describe("Task reasoning preservation", () => { expect(task.apiConversationHistory[0].content[0].text).not.toContain("") }) - it("should embed encrypted reasoning as first assistant content block", async () => { + it("should attach generation metadata to assistant text block", async () => { const task = new Task({ provider: mockProvider as ClineProvider, apiConfiguration: mockApiConfiguration, @@ -337,13 +337,12 @@ describe("Task reasoning preservation", () => { // Avoid disk writes in this test ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - // Mock API handler to provide encrypted reasoning data and response id + // Mock API handler to provide generation metadata task.api = { - getEncryptedContent: vi.fn().mockReturnValue({ - encrypted_content: "encrypted_payload", - id: "rs_test", + getGenerationMetadata: vi.fn().mockReturnValue({ + openAiEncryptedContent: "encrypted_payload", + openAiResponseId: "resp_test", }), - getResponseId: vi.fn().mockReturnValue("resp_test"), } as any await (task as any).addToApiConversationHistory({ @@ -358,17 +357,17 @@ describe("Task reasoning preservation", () => { expect(Array.isArray(stored.content)).toBe(true) expect(stored.id).toBe("resp_test") - const [reasoningBlock, textBlock] = stored.content - - expect(reasoningBlock).toMatchObject({ - type: "reasoning", - encrypted_content: "encrypted_payload", - id: "rs_test", - }) + // Expect a single text block with metadata + expect(stored.content).toHaveLength(1) + const textBlock = stored.content[0] expect(textBlock).toMatchObject({ type: "text", text: "Here is my response.", + providerMetadata: { + openAiEncryptedContent: "encrypted_payload", + openAiResponseId: "resp_test", + }, }) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index 4f4c8a4ae9..d7d043f38a 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -46,6 +46,22 @@ export type ModelRecord = Record export type RouterModels = Record +// Provider Metadata + +export interface ProviderMessageMetadata { + // OpenAI Native + openAiEncryptedContent?: string + openAiReasoningSummary?: any[] + openAiResponseId?: string + + // Google Gemini + geminiThoughtSignature?: string + geminiResponseId?: string + + // OpenRouter + openRouterReasoningDetails?: any[] // Kept generic to match OpenRouter spec +} + // Reasoning export const shouldUseReasoningBudget = ({