mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
feat: enhance generation metadata handling across providers
This commit is contained in:
parent
73c874a0cf
commit
7f352dacf5
9 changed files with 209 additions and 166 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue