feat: enhance generation metadata handling across providers

This commit is contained in:
Hannes Rudolph 2025-11-20 17:01:20 -07:00
parent 73c874a0cf
commit 7f352dacf5
9 changed files with 209 additions and 166 deletions

View file

@ -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,

View file

@ -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({

View file

@ -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<string> {
// Create AbortController for cancellation
this.abortController = new AbortController()

View file

@ -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()

View file

@ -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
}
}
}

View file

@ -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,

View file

@ -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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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,

View file

@ -326,7 +326,7 @@ describe("Task reasoning preservation", () => {
expect(task.apiConversationHistory[0].content[0].text).not.toContain("<think>")
})
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",
},
})
})

View file

@ -46,6 +46,22 @@ export type ModelRecord = Record<string, ModelInfo>
export type RouterModels = Record<RouterName, ModelRecord>
// 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 = ({