Compare commits

...

11 Commits

Author SHA1 Message Date
abeatrix 957dddb6a4 temp changes for testing purpose 2025-11-14 22:22:55 -08:00
abeatrix d9558bf08e feat(api): add Response API support for openai-native provider
Upgrade openai package from v4.83.0 to v6.8.1 to support the new Response API pattern for openai-native provider. This major version update includes:

- Upgraded openai dependency with reduced package footprint
- Refactored message handling to use convertToAnthropicMessage and convertToOpenAIMessages
- Renamed ToolUseHandler to StreamResponseHandler for better clarity
- Updated API stream handling to support new response format
- Reorganized imports across provider implementations
- Updated zod peer dependency requirements (^3.25 || ^4.0)

This change enables better integration with OpenAI's native API responses and improves type safety across provider implementations.
2025-11-14 21:33:46 -08:00
abeatrix b8e413bfd7 clean up 2025-11-11 15:15:30 -08:00
abeatrix adb3dcc616 use new interface 2025-11-11 14:58:52 -08:00
abeatrix ed60898bcb minimax 2025-11-11 14:45:45 -08:00
abeatrix 775cf1a15d remove console log 2025-11-11 14:29:55 -08:00
abeatrix 0de8692dc9 clean up protos 2025-11-11 14:25:44 -08:00
abeatrix 40c2e5341e feat: add model information tracking to tasks and messages
Add modelId field to TaskResponse and TaskItem proto messages, and introduce ClineModelInfo message type to track provider and model IDs throughout the system. Update API transform functions to use ClineStorageMessage types and refactor message handling to support model information tracking.

This enables better tracking and auditing of which AI models are used for specific tasks and messages, improving observability and allowing for model-specific analytics.
2025-11-11 14:20:44 -08:00
abeatrix 8c09a18397 VercelAIGatewayHandler 2025-11-11 11:31:22 -08:00
abeatrix 1d0e51aa97 Merge branch 'main' into bee/thinking-chunk 2025-11-11 11:25:40 -08:00
abeatrix a5d50c9e04 refactor(api): standardize reasoning yield types across providers
- Unify reasoning output format in Anthropic, Cline, and Minimax handlers
- Change "ant_thinking" and "reasoning_details" to "reasoning" type
- Add signature and redacted_data properties for consistency
- Wrap message_start cases in braces for scoping
- Consolidate yields to reduce redundancy and improve maintainability
2025-11-03 17:57:56 -08:00
35 changed files with 1940 additions and 363 deletions
+6 -20
View File
@@ -78,7 +78,7 @@
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"openai": "^6.8.1",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
@@ -14344,23 +14344,16 @@
}
},
"node_modules/openai": {
"version": "4.83.0",
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.8.1.tgz",
"integrity": "sha512-ACifslrVgf+maMz9vqwMP4+v9qvx5Yzssydizks8n+YUJ6YwUoxj51sKRQ8HYMfR6wgKLSIlaI108ZwCk+8yig==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
@@ -14371,13 +14364,6 @@
}
}
},
"node_modules/openai/node_modules/@types/node": {
"version": "18.19.43",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/opossum": {
"version": "9.0.0",
"license": "Apache-2.0",
@@ -18654,4 +18640,4 @@
}
}
}
}
}
+1 -1
View File
@@ -473,7 +473,7 @@
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"openai": "^6.8.1",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
+2
View File
@@ -70,6 +70,7 @@ message TaskResponse {
int32 tokens_out = 8;
int32 cache_writes = 9;
int32 cache_reads = 10;
string model_id = 11;
}
// Request for getting task history with filtering
@@ -99,6 +100,7 @@ message TaskItem {
int32 tokens_out = 8;
int32 cache_writes = 9;
int32 cache_reads = 10;
string model_id = 11;
}
// Request for ask response operation
+6
View File
@@ -184,6 +184,11 @@ message ClineApiReqInfo {
ApiReqRetryStatus retry_status = 9;
}
message ClineModelInfo {
string provider_id = 1;
string model_id = 2;
}
// Main ClineMessage type
message ClineMessage {
int64 ts = 1;
@@ -210,6 +215,7 @@ message ClineMessage {
ClineAskQuestion ask_question = 20;
ClineAskNewTask ask_new_task = 21;
ClineApiReqInfo api_req_info = 22;
ClineModelInfo model_info = 23;
}
// UiService provides methods for managing UI interactions
+18 -41
View File
@@ -6,7 +6,7 @@ import { fetch } from "@/shared/net"
import { ClineTool } from "@/shared/tools"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
import { convertToAnthropicMessage } from "../transform/anthropic-format"
import { ApiStream } from "../transform/stream"
interface AnthropicHandlerOptions extends CommonApiHandlerOptions {
@@ -74,19 +74,7 @@ export class AnthropicHandler implements ApiHandler {
case "claude-opus-4-1-20250805":
case "claude-3-opus-20240229":
case "claude-3-haiku-20240307": {
/*
The latest message will be the new user message, one before will be the assistant message from a previous request, and the user message before that will be a previously cached user message. So we need to mark the latest user message as ephemeral to cache it for the next request, and mark the second to last user message as ephemeral to let the server know the last message to retrieve from the cache for the current request..
*/
const userMsgIndices = messages.reduce((acc, msg, index) => {
if (msg.role === "user") {
acc.push(index)
}
return acc
}, [] as number[])
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
const anthropicMessages = sanitizeAnthropicMessages(messages, lastUserMsgIndex, secondLastMsgUserIndex)
const anthropicMessages = convertToAnthropicMessage(messages, true)
stream = await client.messages.create(
{
@@ -135,7 +123,7 @@ export class AnthropicHandler implements ApiHandler {
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
system: [{ text: systemPrompt, type: "text" }],
messages: sanitizeAnthropicMessages(messages),
messages: convertToAnthropicMessage(messages, false),
// tools,
// tool_choice: { type: "auto" },
stream: true,
@@ -144,20 +132,21 @@ export class AnthropicHandler implements ApiHandler {
}
}
let thinkingDeltaAccumulator = ""
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start":
// tells us cache reads/writes/input/output
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
{
// tells us cache reads/writes/input/output
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
}
break
case "message_delta":
@@ -178,15 +167,7 @@ export class AnthropicHandler implements ApiHandler {
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
const thinking = chunk.content_block.thinking
const signature = chunk.content_block.signature
if (thinking && signature) {
yield {
type: "ant_thinking",
thinking,
signature,
}
signature: chunk.content_block.signature,
}
break
case "redacted_thinking":
@@ -194,10 +175,7 @@ export class AnthropicHandler implements ApiHandler {
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
yield {
type: "ant_redacted_thinking",
data: chunk.content_block.data,
redacted_data: chunk.content_block.data,
}
break
case "tool_use":
@@ -231,15 +209,14 @@ export class AnthropicHandler implements ApiHandler {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
thinkingDeltaAccumulator += chunk.delta.thinking
break
case "signature_delta":
// It's used when sending the thinking block back to the API
// API expects this in completed form, not as array of deltas
if (thinkingDeltaAccumulator && chunk.delta.signature) {
if (chunk.delta.signature) {
yield {
type: "ant_thinking",
thinking: thinkingDeltaAccumulator,
type: "reasoning",
reasoning: "", // reasoning text is already sent via thinking_delta
signature: chunk.delta.signature,
}
}
+4 -4
View File
@@ -165,8 +165,7 @@ export class ClineHandler implements ApiHandler {
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
}
}
@@ -185,8 +184,9 @@ export class ClineHandler implements ApiHandler {
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning_details",
reasoning_details: delta.reasoning_details,
type: "reasoning",
reasoning: "",
details: delta.reasoning_details,
}
}
+10 -16
View File
@@ -66,12 +66,11 @@ export class MinimaxHandler implements ApiHandler {
tool_choice: nativeToolsOn ? { type: "any" } : undefined,
})
let thinkingDeltaAccumulator = ""
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start":
case "message_start": {
// tells us cache reads/writes/input/output
const usage = chunk.message.usage
yield {
@@ -82,6 +81,7 @@ export class MinimaxHandler implements ApiHandler {
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
}
case "message_delta":
// tells us stop_reason, stop_sequence, and output tokens along the way and at the end of the message
yield {
@@ -100,13 +100,11 @@ export class MinimaxHandler implements ApiHandler {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
const thinking = chunk.content_block.thinking
const signature = chunk.content_block.signature
if (thinking && signature) {
if (chunk.content_block.thinking && chunk.content_block.signature) {
yield {
type: "ant_thinking",
thinking,
signature,
type: "reasoning",
reasoning: chunk.content_block.thinking,
signature: chunk.content_block.signature,
}
}
break
@@ -115,10 +113,7 @@ export class MinimaxHandler implements ApiHandler {
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
yield {
type: "ant_redacted_thinking",
data: chunk.content_block.data,
redacted_data: chunk.content_block.data,
}
break
case "tool_use":
@@ -152,15 +147,14 @@ export class MinimaxHandler implements ApiHandler {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
thinkingDeltaAccumulator += chunk.delta.thinking
break
case "signature_delta":
// It's used when sending the thinking block back to the API
// API expects this in completed form, not as array of deltas
if (thinkingDeltaAccumulator && chunk.delta.signature) {
if (chunk.delta.signature) {
yield {
type: "ant_thinking",
thinking: thinkingDeltaAccumulator,
type: "reasoning",
reasoning: "",
signature: chunk.delta.signature,
}
}
+5 -4
View File
@@ -1,7 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { LiteLLMModelInfo, liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults } from "@shared/api"
import OpenAI, { APIError, OpenAIError } from "openai"
import type { FinalRequestOptions, Headers as OpenAIHeaders } from "openai/core"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import {
@@ -38,7 +37,7 @@ export class OcaHandler implements ApiHandler {
protected initializeClient(options: OcaHandlerOptions) {
return new (class OCIOpenAI extends OpenAI {
protected override async prepareOptions(opts: FinalRequestOptions<unknown>): Promise<void> {
protected override async prepareOptions(opts: any): Promise<void> {
const token = await OcaAuthService.getInstance().getAuthToken()
if (!token) {
throw new OpenAIError("Unable to handle auth, Oracle Code Assist (OCA) access token is not available")
@@ -55,7 +54,7 @@ export class OcaHandler implements ApiHandler {
status: number | undefined,
error: Object | undefined,
message: string | undefined,
headers: OpenAIHeaders | undefined,
headers: any,
): APIError {
interface OciError {
code?: string
@@ -75,7 +74,9 @@ export class OcaHandler implements ApiHandler {
if (opcRequestId) {
ociErrorMessage += `\n(${OCI_HEADER_OPC_REQUEST_ID}: ${opcRequestId})`
}
return super.makeStatusError(status, error, ociErrorMessage, headers)
// Coerce possibly-undefined values to the base class' expected types
const statusCode = typeof status === "number" ? status : 500
return super.makeStatusError(statusCode, error ?? {}, ociErrorMessage, headers)
}
})({
baseURL:
+223 -3
View File
@@ -1,12 +1,14 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo, OpenAiNativeModelId, openAiNativeDefaultModelId, openAiNativeModels } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
import { Logger } from "@/services/logging/Logger"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToResponsesInput } from "../transform/openai-response-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
@@ -61,7 +63,22 @@ export class OpenAiNativeHandler implements ApiHandler {
@withRetry()
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
messages: ClineStorageMessage[],
tools?: ChatCompletionTool[],
// Whether to use the Responses API format.
// NOTE: should be controlled by feature flag before it goes live
useResponseFormat = true, // TODO: SET TO FALSE BEFORE RELEASE - HARD CODED TRUE FOR TESTING ONLY
): ApiStream {
if (useResponseFormat) {
yield* this.createResponseStream(systemPrompt, messages, tools)
} else {
yield* this.createCompletionStream(systemPrompt, messages, tools)
}
}
private async *createCompletionStream(
systemPrompt: string,
messages: ClineStorageMessage[],
tools?: ChatCompletionTool[],
): ApiStream {
const client = this.ensureClient()
@@ -114,7 +131,7 @@ export class OpenAiNativeHandler implements ApiHandler {
}
case "gpt-5-2025-08-07":
case "gpt-5-mini-2025-08-07":
case "gpt-5-nano-2025-08-07":
case "gpt-5-nano-2025-08-07": {
const stream = await client.chat.completions.create({
model: model.id,
temperature: 1,
@@ -148,6 +165,7 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
break
}
default: {
const stream = await client.chat.completions.create({
model: model.id,
@@ -181,6 +199,208 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
private async *createResponseStream(
systemPrompt: string,
messages: ClineStorageMessage[],
tools?: ChatCompletionTool[],
): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
// Convert messages to Responses API input format
const input = convertToResponsesInput(messages)
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
?.filter((tool) => tool.type === "function")
.map((tool: any) => ({
type: "function" as const,
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
}))
Logger.debug("OpenAI Responses Input: " + JSON.stringify(input))
// const lastAssistantMessage = [...messages].reverse().find((msg) => msg.role === "assistant" && msg.id)
// const previous_response_id = lastAssistantMessage?.id
// Create the response using Responses API
const stream = await client.responses.create({
model: model.id,
instructions: systemPrompt,
input,
stream: true,
tools: responseTools,
// previous_response_id,
// store: true,
reasoning: { effort: "medium", summary: "auto" },
// include: ["reasoning.encrypted_content"],
})
// Process the response stream
for await (const chunk of stream) {
Logger.debug("OpenAI Responses Chunk: " + JSON.stringify(chunk))
// Handle different event types from Responses API
if (chunk.type === "response.output_item.added") {
const item = chunk.item
if (item.type === "function_call" && item.id) {
yield {
type: "tool_calls",
id: item.id,
tool_call: {
call_id: item.call_id,
function: {
id: item.id,
name: item.name,
arguments: item.arguments,
},
},
}
}
if (item.type === "reasoning" && item.encrypted_content && item.id) {
yield {
type: "reasoning",
id: item.id,
reasoning: "",
redacted_data: item.encrypted_content,
}
}
}
if (chunk.type === "response.output_item.done") {
const item = chunk.item
if (item.type === "function_call") {
yield {
type: "tool_calls",
id: item.id || item.call_id,
tool_call: {
call_id: item.call_id,
function: {
id: item.id,
name: item.name,
arguments: item.arguments,
},
},
}
}
if (item.type === "reasoning") {
yield {
type: "reasoning",
id: item.id,
details: item.summary,
reasoning: "",
}
}
}
if (chunk.type === "response.reasoning_summary_part.added") {
yield {
type: "reasoning",
id: chunk.item_id,
reasoning: chunk.part.text,
}
}
if (chunk.type === "response.reasoning_summary_text.delta") {
yield {
type: "reasoning",
id: chunk.item_id,
reasoning: chunk.delta,
}
}
if (chunk.type === "response.reasoning_summary_part.done") {
yield {
type: "reasoning",
id: chunk.item_id,
details: chunk.part,
reasoning: "",
}
}
if (chunk.type === "response.output_text.delta") {
// Handle text content deltas
if (chunk.delta) {
yield {
id: chunk.item_id,
type: "text",
text: chunk.delta,
}
}
}
if (chunk.type === "response.reasoning_text.delta") {
// Handle reasoning content deltas
if (chunk.delta) {
yield {
id: chunk.item_id,
type: "reasoning",
reasoning: chunk.delta,
}
}
}
if (chunk.type === "response.function_call_arguments.delta") {
yield {
type: "tool_calls",
tool_call: {
function: {
id: chunk.item_id,
name: chunk.item_id,
arguments: chunk.delta,
},
},
}
}
if (chunk.type === "response.function_call_arguments.done") {
// Handle completed function call
if (chunk.item_id && chunk.name && chunk.arguments) {
yield {
type: "tool_calls",
tool_call: {
function: {
id: chunk.item_id,
name: chunk.name,
arguments: chunk.arguments,
},
},
}
}
}
if (
chunk.type === "response.incomplete" &&
chunk.response?.status === "incomplete" &&
chunk.response?.incomplete_details?.reason === "max_output_tokens"
) {
console.log("Ran out of tokens")
if (chunk.response?.output_text?.length > 0) {
console.log("Partial output:", chunk.response.output_text)
} else {
console.log("Ran out of tokens during reasoning")
}
}
if (chunk.type === "response.completed" && chunk.response?.usage) {
// Handle usage information when response is complete
const usage = chunk.response.usage
const inputTokens = usage.input_tokens || 0
const outputTokens = usage.output_tokens || 0
const cacheReadTokens = usage.output_tokens_details?.reasoning_tokens || 0
const cacheWriteTokens = usage.input_tokens_details?.cached_tokens || 0
const totalTokens = usage.total_tokens || 0
Logger.log(`Total tokens from Responses API usage: ${totalTokens}`)
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
yield {
type: "usage",
inputTokens: nonCachedInputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
id: chunk.response.id,
}
}
}
}
getModel(): { id: OpenAiNativeModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in openAiNativeModels) {
+4 -4
View File
@@ -127,8 +127,7 @@ export class OpenRouterHandler implements ApiHandler {
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
}
}
@@ -142,8 +141,9 @@ export class OpenRouterHandler implements ApiHandler {
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning_details",
reasoning_details: delta.reasoning_details,
type: "reasoning",
reasoning: "",
details: delta.reasoning_details,
}
}
+3 -2
View File
@@ -95,8 +95,9 @@ export class VercelAIGatewayHandler implements ApiHandler {
delta.reasoning_details.length // exists and non-0
) {
yield {
type: "reasoning_details",
reasoning_details: delta.reasoning_details,
type: "reasoning",
reasoning: "",
details: delta.reasoning_details,
}
}
+5 -11
View File
@@ -6,7 +6,7 @@ import { ModelInfo, VertexModelId, vertexDefaultModelId, vertexModels } from "@s
import { ClineTool } from "@/shared/tools"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
import { convertToAnthropicMessage } from "../transform/anthropic-format"
import { ApiStream } from "../transform/stream"
import { GeminiHandler } from "./gemini"
@@ -103,13 +103,6 @@ export class VertexHandler implements ApiHandler {
case "claude-3-5-haiku@20241022":
case "claude-3-opus@20240229":
case "claude-3-haiku@20240307": {
// Find indices of user messages for cache control
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await clientAnthropic.beta.messages.create(
{
model: modelId,
@@ -123,7 +116,7 @@ export class VertexHandler implements ApiHandler {
cache_control: { type: "ephemeral" },
},
],
messages: sanitizeAnthropicMessages(messages, lastUserMsgIndex, secondLastMsgUserIndex),
messages: convertToAnthropicMessage(messages, true),
stream: true,
tools: tools?.length ? (tools as AnthropicTool[]) : undefined,
// tool_choice options:
@@ -149,7 +142,7 @@ export class VertexHandler implements ApiHandler {
type: "text",
},
],
messages: sanitizeAnthropicMessages(messages),
messages: convertToAnthropicMessage(messages, false),
stream: true,
tools: tools?.length ? (tools as AnthropicTool[]) : undefined,
// tool_choice options:
@@ -166,7 +159,7 @@ export class VertexHandler implements ApiHandler {
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start":
case "message_start": {
const usage = chunk.message.usage
yield {
type: "usage",
@@ -176,6 +169,7 @@ export class VertexHandler implements ApiHandler {
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
}
case "message_delta":
yield {
type: "usage",
+75 -62
View File
@@ -1,77 +1,90 @@
import { MessageParam } from "@anthropic-ai/sdk/resources/index"
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/shared/messages/content"
/**
* Sanitize Anthropic messages by removing reasoning details and adding ephemeral cache control
* to the last two user messages to prevent them from being stored in Anthropic's cache.
* Converts Cline storage messages to Anthropic API format with optional cache control.
* Adds ephemeral cache control to the last two user messages to prevent them from being
* stored in Anthropic's cache.
*
* @param clineMessages - Array of Cline storage messages to convert
* @param lastUserMsgIndex - Optional index of the last user message
* @param secondLastMsgUserIndex - Optional index of the second-to-last user message
* @returns Array of Anthropic-compatible messages with cache control applied
*/
export function sanitizeAnthropicMessages(
messages: Array<MessageParam>,
lastUserMsgIndex?: number,
secondLastMsgUserIndex?: number,
): Array<MessageParam> {
return messages.map((_message, index) => {
const message = removeReasoningDetails(_message)
const addCacheControl = lastUserMsgIndex !== undefined && secondLastMsgUserIndex !== undefined
export function convertToAnthropicMessage(
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
supportCache: boolean,
): Array<Anthropic.MessageParam> {
// The latest message will be the new user message, one before will be the assistant message from a previous request,
// and the user message before that will be a previously cached user message. So we need to mark the latest user message
// as ephemeral to cache it for the next request, and mark the second to last user message as ephemeral to let the server
// know the last message to retrieve from the cache for the current request.
const userMsgIndices = clineMessages.reduce((acc, msg, index) => {
if (msg.role === "user") {
acc.push(index)
}
return acc
}, [] as number[])
// Set to -1 if there are no user messages so the indices are invalid
const indicesLength = userMsgIndices.length ?? -1
const lastUserMsgIndex = userMsgIndices[indicesLength - 1]
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2]
if (addCacheControl && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
cache_control: {
type: "ephemeral",
},
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
cache_control: {
type: "ephemeral",
},
}
: content,
),
}
return clineMessages.map((msg, index) => {
const anthropicMsg = convertClineStorageToAnthropicMessage(msg)
// Add cache control to the last two user messages
if (supportCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)) {
return addCacheControl(anthropicMsg)
}
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
}
return anthropicMsg
})
}
const isThinkingBlock = (
block: Anthropic.ContentBlockParam,
): block is Anthropic.Messages.ThinkingBlockParam | Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking"
}
/**
* Remove reasoning details from a single Anthropic message parameter
* Adds ephemeral cache control to the last content block of a message.
* Returns a new message object without mutating the original.
*
* @param message - The Anthropic message to add cache control to
* @returns A new message with cache control added to the last content block
*/
function removeReasoningDetails(param: MessageParam): MessageParam {
if (Array.isArray(param.content)) {
function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessageParam {
// Convert string content to array format
if (typeof message.content === "string") {
return {
...param,
content: param.content.map((item) => {
if (item.type === "text") {
return {
...item,
reasoning_details: undefined,
}
}
return item
}),
...message,
content: [
{
type: "text",
text: message.content,
cache_control: { type: "ephemeral" },
} satisfies Anthropic.TextBlockParam,
],
}
}
return param
// Handle array content - add cache control to the last block
const content = [...message.content]
const lastIndex = content.length - 1
if (lastIndex >= 0) {
const lastBlock = content[lastIndex]
// Only add cache_control to block types that support it (not ThinkingBlockParam)
if (!isThinkingBlock(lastBlock)) {
content[lastIndex] = {
...lastBlock,
cache_control: { type: "ephemeral" },
} satisfies Anthropic.ContentBlockParam
}
}
return { ...message, content }
}
+12 -6
View File
@@ -1,7 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Message } from "ollama"
import {
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
const ollamaMessages: Message[] = []
for (const anthropicMessage of anthropicMessages) {
@@ -13,8 +19,8 @@ export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.Me
} else {
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
@@ -70,8 +76,8 @@ export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.Me
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
+40 -21
View File
@@ -1,8 +1,17 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import {
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
export function convertToOpenAiMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
@@ -23,8 +32,8 @@ export function convertToOpenAiMessages(
*/
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
@@ -38,7 +47,7 @@ export function convertToOpenAiMessages(
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
const toolResultImages: ClineImageContentBlock[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
@@ -102,8 +111,13 @@ export function convertToOpenAiMessages(
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
@@ -119,6 +133,7 @@ export function convertToOpenAiMessages(
// Process non-tool messages
let content: string | undefined
const reasoningDetails: any[] = []
const thinkingBlock = []
if (nonToolMessages.length > 0) {
nonToolMessages.forEach((part) => {
// @ts-ignore-next-line
@@ -134,13 +149,16 @@ export function convertToOpenAiMessages(
// @ts-ignore-next-line
// delete part.reasoning_details
}
if (part.type === "thinking" && part.thinking) {
thinkingBlock.push(part)
}
})
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
if (part.type === "text" && part.text) {
return part.text
}
return part.text
return ""
})
.join("\n")
}
@@ -321,29 +339,30 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
}
try {
if (openAiMessage?.tool_calls?.length) {
anthropicMessage.content.push(
...openAiMessage.tool_calls
.map((toolCall): Anthropic.ToolUseBlock => {
const parsedName = toolCall.type === "function" && toolCall.function.name
let parsedInput = toolCall.function.arguments
const functionCalls = openAiMessage.tool_calls.filter((tc: any) => tc?.type === "function" && tc.function)
if (functionCalls.length > 0) {
anthropicMessage.content.push(
...functionCalls.map((toolCall: any): Anthropic.ToolUseBlock => {
let parsedInput = {}
try {
parsedInput = JSON.parse(toolCall.function.arguments || "{}")
parsedInput = JSON.parse(toolCall.function?.arguments || "{}")
} catch (error) {
console.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
id: toolCall.id,
name: parsedName || UNIQUE_ERROR_TOOL_NAME,
name: toolCall.function?.name || UNIQUE_ERROR_TOOL_NAME,
input: parsedInput,
}
})
// Filter out any tool uses with the UNIQUE_ERROR_TOOL_NAME, which indicates a parsing error
.filter((toolUse) => toolUse.name !== UNIQUE_ERROR_TOOL_NAME),
)
}),
)
}
return anthropicMessage
}
} catch (error) {
console.error("Failed to process tool calls:", error)
console.error("Error converting OpenAI message to Anthropic format:", error)
}
return anthropicMessage
@@ -0,0 +1,206 @@
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
import { ClineStorageMessage } from "@/shared/messages/content"
/**
* Converts an array of ClineStorageMessage objects (in Anthropic format) to a ResponseInput array
* for use with OpenAI's Responses API.
*
* ## Key Differences from Chat Completions API
*
* The Responses API has stricter requirements than the Chat Completions API:
*
* ### Chat Completions API:
* - Messages are simple role/content pairs
* - System prompts are separate messages with role="system"
* - No explicit reasoning item structure
* - More forgiving about message ordering
*
* ### Responses API:
* - Uses an "input" array of heterogeneous items (messages, reasoning, function_calls, etc.)
* - System prompts go in an "instructions" field, not as messages
* - Reasoning items MUST be immediately followed by a message or function_call
* - Strict ordering requirements match training data distribution
*
* ## The Reasoning Item Constraint
*
* **THE CRITICAL ERROR:** "Item 'rs_...' of type 'reasoning' was provided without its required following item"
*
* This error occurs when reasoning items are orphaned or separated from their corresponding output.
*
* ### What Causes This Error:
* ```
* ❌ WRONG - Reasoning orphaned between turns:
* [
* { role: "user", content: [...] },
* { type: "reasoning", id: "rs_abc", summary: [...] }, // ← ORPHANED!
* { type: "message", role: "assistant", content: [...] },
* { role: "user", content: [...] }
* ]
* ```
*
* ### The Fix - Keep Complete Assistant Turns Together:
* ```
* ✅ CORRECT - Reasoning paired with its message:
* [
* { role: "user", content: [...] },
* { type: "reasoning", id: "rs_abc", summary: [...] },
* { type: "message", role: "assistant", content: [...] }, // ← Immediately follows reasoning
* { role: "user", content: [...] }
* ]
* ```
*
* **Per OpenAI Engineering Guidance:**
* - ❌ WRONG: `content += filter(lambda x: x.type == "reasoning", resp.output)`
* - ✅ CORRECT: `content += resp.output`
*
* Never extract only reasoning items - always include the complete output sequence
* (reasoning + message/function_call) as provided by the API.
*
* ## Implementation Strategy
*
* 1. **Separate processing for assistant vs user messages** - Assistant turns need special
* handling to maintain reasoning-message pairing
* 2. **Collect all assistant items together** - Gather reasoning, messages, and function_calls
* for the entire assistant turn before validating
* 3. **Validate pairing within each turn** - Ensure each reasoning item is immediately followed
* by a message or function_call, inserting placeholders if needed
* 4. **Flush complete turns atomically** - Add all items from an assistant turn together to
* maintain proper sequencing
*
* @link https://community.openai.com/t/openai-api-error-function-call-was-provided-without-its-required-reasoning-item-the-real-issue/1355347
*
* @param messages - Array of ClineStorageMessage objects to be converted
* @returns ResponseInput array containing the transformed messages with proper reasoning pairing
*/
export function convertToResponsesInput(messages: ClineStorageMessage[]): ResponseInput {
const allItems: any[] = []
const toolUseIdToCallId = new Map<string, string>()
for (const m of messages) {
if (typeof m.content === "string") {
allItems.push({ role: m.role, content: [{ type: "input_text", text: m.content }] })
continue
}
if (m.role === "assistant") {
// For assistant messages, we must ensure reasoning items are IMMEDIATELY followed
// by their corresponding message or function_call. Process the entire assistant
// turn and ensure proper pairing.
const assistantItems: any[] = []
for (const part of m.content) {
switch (part.type) {
case "thinking":
if (part.thinking && part.call_id && part.call_id.length > 0) {
assistantItems.push({
id: part.call_id,
type: "reasoning",
summary: [
{
type: "summary_text",
text: part.thinking,
},
],
} as ResponseReasoningItem)
}
break
case "redacted_thinking":
if (part.data && part.call_id && part.call_id.length > 0) {
assistantItems.push({
id: part.call_id,
type: "reasoning",
encrypted_content: part.data,
summary: [],
} as ResponseReasoningItem)
}
break
case "text":
assistantItems.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: part.text }],
})
break
case "image":
assistantItems.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: `[image:${part.source.media_type}]` }],
})
break
case "tool_use": {
const call_id = part.call_id || part.id
if (part.call_id) {
toolUseIdToCallId.set(part.id, part.call_id)
}
assistantItems.push({
type: "function_call",
call_id,
id: part.id,
name: part.name,
arguments: JSON.stringify(part.input ?? {}),
})
break
}
}
}
// Ensure every reasoning item is followed by a message or function_call
for (let i = 0; i < assistantItems.length; i++) {
const item = assistantItems[i]
if (item.type === "reasoning") {
const nextItem = assistantItems[i + 1]
if (!nextItem || (nextItem.type !== "message" && nextItem.type !== "function_call")) {
// Insert a placeholder message immediately after this reasoning item
assistantItems.splice(i + 1, 0, {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "" }],
})
}
}
}
allItems.push(...assistantItems)
} else {
// User messages - collect all content
const messageContent: ResponseInputMessageContentList = []
for (const part of m.content) {
switch (part.type) {
case "text":
messageContent.push({ type: "input_text", text: part.text })
break
case "image":
messageContent.push({
type: "input_image",
detail: "auto",
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
})
break
case "tool_result": {
// Flush any pending message content before adding tool result
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] })
messageContent.length = 0
}
const call_id = part.call_id || toolUseIdToCallId.get(part.tool_use_id) || part.tool_use_id
allItems.push({
type: "function_call_output",
call_id,
output: typeof part.content === "string" ? part.content : JSON.stringify(part.content),
})
break
}
}
}
// Flush any remaining user message content
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] })
}
}
}
return allItems
}
+42 -31
View File
@@ -1,37 +1,10 @@
export type ApiStream = AsyncGenerator<ApiStreamChunk>
export type ApiStreamChunk =
| ApiStreamTextChunk
| ApiStreamReasoningChunk
| ApiStreamReasoningDetailsChunk
| ApiStreamAnthropicThinkingChunk
| ApiStreamAnthropicRedactedThinkingChunk
| ApiStreamUsageChunk
| ApiStreamToolCallsChunk
export type ApiStream = AsyncGenerator<ApiStreamChunk> & { id?: string }
export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamThinkingChunk | ApiStreamUsageChunk | ApiStreamToolCallsChunk
export interface ApiStreamTextChunk {
type: "text"
text: string
}
export interface ApiStreamReasoningChunk {
type: "reasoning"
reasoning: string
}
export interface ApiStreamReasoningDetailsChunk {
type: "reasoning_details"
reasoning_details: any // openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
}
export interface ApiStreamAnthropicThinkingChunk {
type: "ant_thinking"
thinking: string
signature: string
}
export interface ApiStreamAnthropicRedactedThinkingChunk {
type: "ant_redacted_thinking"
data: string
id?: string // The response ID associated with this chunk
}
export interface ApiStreamUsageChunk {
@@ -42,15 +15,26 @@ export interface ApiStreamUsageChunk {
cacheReadTokens?: number
thoughtsTokenCount?: number // openrouter
totalCost?: number // openrouter
/**
* The response ID associated with this chunk
*/
id?: string
}
export interface ApiStreamToolCallsChunk {
type: "tool_calls"
tool_call: ApiStreamToolCall
/**
* The response ID associated with this chunk
*/
id?: string
}
export interface ApiStreamToolCall {
call_id?: string // The call / request ID associated with this tool call
/**
* The response ID associated with this chunk
*/
call_id?: string
// Information about the tool being called
function: {
id?: string // The tool call ID
@@ -58,3 +42,30 @@ export interface ApiStreamToolCall {
arguments?: any
}
}
export interface ApiStreamThinkingChunk {
type: "reasoning"
/**
* The reasoning text generated by the model.
* Redacted reasoning block will have this field set to "[REDACTED]" or an empty string.
*/
reasoning: string
/**
* openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
* This is also where we store the summary details for OpenAI.
*/
details?: unknown
/**
* It's used when sending the thinking block back to the API.
* API expects this in completed form, not as array of deltas.
*/
signature?: string
/**
* redacted data
*/
redacted_data?: string
/**
* The response ID associated with this chunk
*/
id?: string
}
+40 -3
View File
@@ -1,9 +1,10 @@
import { ClineDefaultTool } from "@shared/tools"
export type AssistantMessageContent = TextContent | ToolUse
export type AssistantMessageContent = TextStreamContent | ToolUse | ReasoningStreamContent
export { parseAssistantMessageV2 } from "./parse-assistant-message"
export interface TextContent {
export interface TextStreamContent {
type: "text"
content: string
partial: boolean
@@ -51,6 +52,42 @@ export interface ToolUse {
// params is a partial record, allowing only some or none of the possible parameters to be used
params: Partial<Record<ToolParamName, string>>
partial: boolean
// Whether this tool use was initiated by a native tool call
/**
* Whether this tool use was initiated by a native tool call
*/
isNativeToolCall?: boolean
/**
* The call / response ID this tool use is associated with.
*/
call_id?: string // optional call ID for tracking tool use calls
}
export interface ReasoningStreamContent {
type: "reasoning"
/**
* The reasoning text generated by the model.
* Redacted reasoning block will have this field set to "[REDACTED]" or an empty string.
*/
reasoning: string
/**
* openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
*/
details?: any
/**
* It's used when sending the thinking block back to the API.
* API expects this in completed form, not as array of deltas.
*/
signature?: string
/**
* whether this reasoning block has been redacted
*/
redacted?: boolean
/**
* redacted data
*/
data?: string
/**
* Indicates whether this is a partial reasoning block
*/
partial: boolean
}
@@ -1,5 +1,5 @@
import { ClineDefaultTool, toolUseNames } from "@shared/tools"
import { AssistantMessageContent, TextContent, ToolParamName, ToolUse, toolParamNames } from "." // Assuming types are defined in index.ts or a similar file
import { AssistantMessageContent, TextStreamContent, ToolParamName, ToolUse, toolParamNames } from "." // Assuming types are defined in index.ts or a similar file
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
@@ -27,7 +27,7 @@ import { AssistantMessageContent, TextContent, ToolParamName, ToolUse, toolParam
export function parseAssistantMessageV2(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContentStart = 0 // Index where the current text block started
let currentTextContent: TextContent | undefined
let currentTextContent: TextStreamContent | undefined
let currentToolUseStart = 0 // Index *after* the opening tag of the current tool use
let currentToolUse: ToolUse | undefined
let currentParamValueStart = 0 // Index *after* the opening tag of the current param
@@ -189,7 +189,11 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
modelInfo.outputPrice = 3
modelInfo.contextWindow = 131_000
break
case "openai/gpt-5.1":
case "openai/gpt-5.1-codex":
case "openai/gpt-5.1-codex-mini":
case "openai/gpt-5":
case "openai/gpt-5-codex":
case "openai/gpt-5-chat":
case "openai/gpt-5-mini":
case "openai/gpt-5-nano":
@@ -103,6 +103,7 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
tokensOut: item.tokensOut || 0,
cacheWrites: item.cacheWrites || 0,
cacheReads: item.cacheReads || 0,
modelId: item.modelId || "",
}))
return TaskHistoryArray.create({
@@ -0,0 +1,671 @@
/**
* Unit Tests for System Prompt Tool Specification Functions
*
* This test suite validates the tool conversion functions that transform
* ClineToolSpec into various provider-specific formats (OpenAI, Anthropic, Google).
*/
import { expect } from "chai"
import type { ChatCompletionTool } from "openai/resources/chat/completions"
import { ModelFamily } from "@/shared/prompts"
import type { ClineDefaultTool } from "@/shared/tools"
import {
type ClineToolSpec,
openAIToolToAnthropic,
toOpenAIResponsesAPITool,
toOpenAIResponseTools,
toolSpecFunctionDeclarations,
toolSpecFunctionDefinition,
toolSpecInputSchema,
} from "../spec"
import type { SystemPromptContext } from "../types"
const mockProviderInfo = {
providerId: "test",
model: {
id: "test-model",
info: {
supportsPromptCache: false,
},
},
}
const baseContext: SystemPromptContext = {
cwd: "/test/project",
ide: "TestIDE",
supportsBrowserUse: false,
mcpHub: undefined,
focusChainSettings: undefined,
browserSettings: {
viewport: {
width: 1280,
height: 720,
},
},
providerInfo: mockProviderInfo,
isTesting: true,
enableNativeToolCalls: false,
}
const mockToolSpec: ClineToolSpec = {
variant: ModelFamily.GENERIC,
id: "test_tool" as ClineDefaultTool,
name: "test_tool",
description: "A test tool for unit testing",
parameters: [
{
name: "param1",
required: true,
instruction: "First parameter",
type: "string",
},
{
name: "param2",
required: false,
instruction: "Second parameter",
type: "integer",
},
],
}
describe("Tool Specification Functions", () => {
describe("toolSpecFunctionDefinition", () => {
it("should convert ClineToolSpec to OpenAI ChatCompletionTool", () => {
const result = toolSpecFunctionDefinition(mockToolSpec, baseContext)
expect(result).to.have.property("type", "function")
expect(result).to.have.nested.property("function.name", "test_tool")
expect(result).to.have.nested.property("function.description", "A test tool for unit testing")
expect(result).to.have.nested.property("function.strict", false)
expect(result).to.have.nested.property("function.parameters.type", "object")
expect(result).to.have.nested.property("function.parameters.properties.param1")
expect(result).to.have.nested.property("function.parameters.properties.param2")
if (result.type === "function") {
expect(result.function.parameters?.required).to.deep.equal(["param1"])
}
})
it("should handle tool without parameters", () => {
const toolWithoutParams: ClineToolSpec = {
...mockToolSpec,
parameters: undefined,
}
const result = toolSpecFunctionDefinition(toolWithoutParams, baseContext)
if (result.type === "function") {
expect(result.function.parameters?.properties).to.deep.equal({})
expect(result.function.parameters?.required).to.deep.equal([])
}
})
it("should throw error when context requirements are not met", () => {
const toolWithContextReq: ClineToolSpec = {
...mockToolSpec,
contextRequirements: () => false,
}
expect(() => toolSpecFunctionDefinition(toolWithContextReq, baseContext)).to.throw(
"Tool test_tool does not meet context requirements",
)
})
it("should filter out parameters that don't meet context requirements", () => {
const toolWithConditionalParams: ClineToolSpec = {
...mockToolSpec,
parameters: [
{
name: "visible_param",
required: true,
instruction: "Always visible",
type: "string",
},
{
name: "hidden_param",
required: false,
instruction: "Hidden param",
type: "string",
contextRequirements: () => false,
},
],
}
const result = toolSpecFunctionDefinition(toolWithConditionalParams, baseContext)
if (result.type === "function") {
expect(result.function.parameters?.properties).to.have.property("visible_param")
expect(result.function.parameters?.properties).to.not.have.property("hidden_param")
}
})
it("should replace browser viewport placeholders in descriptions", () => {
const toolWithPlaceholders: ClineToolSpec = {
...mockToolSpec,
description: "Width: {{BROWSER_VIEWPORT_WIDTH}}, Height: {{BROWSER_VIEWPORT_HEIGHT}}",
}
const result = toolSpecFunctionDefinition(toolWithPlaceholders, baseContext)
if (result.type === "function") {
expect(result.function.description).to.equal("Width: 1280, Height: 720")
}
})
it("should handle array type parameters", () => {
const toolWithArray: ClineToolSpec = {
...mockToolSpec,
parameters: [
{
name: "items",
required: true,
instruction: "Array of items",
type: "array",
items: { type: "string" },
},
],
}
const result = toolSpecFunctionDefinition(toolWithArray, baseContext)
if (result.type === "function") {
const properties = result.function.parameters?.properties as any
expect(properties.items).to.have.property("type", "array")
expect(properties.items).to.have.property("items")
}
})
it("should handle object type parameters", () => {
const toolWithObject: ClineToolSpec = {
...mockToolSpec,
parameters: [
{
name: "config",
required: true,
instruction: "Configuration object",
type: "object",
properties: {
key1: { type: "string" },
key2: { type: "number" },
},
},
],
}
const result = toolSpecFunctionDefinition(toolWithObject, baseContext)
if (result.type === "function") {
const properties = result.function.parameters?.properties as any
expect(properties.config).to.have.property("type", "object")
expect(properties.config).to.have.property("properties")
}
})
it("should preserve additional JSON Schema fields", () => {
const toolWithExtendedSchema: ClineToolSpec = {
...mockToolSpec,
parameters: [
{
name: "status",
required: true,
instruction: "Status value",
type: "string",
enum: ["active", "inactive", "pending"],
minLength: 1,
maxLength: 20,
},
],
}
const result = toolSpecFunctionDefinition(toolWithExtendedSchema, baseContext)
if (result.type === "function") {
const properties = result.function.parameters?.properties as any
expect(properties.status).to.have.property("enum")
expect(properties.status).to.have.property("minLength", 1)
expect(properties.status).to.have.property("maxLength", 20)
}
})
})
describe("toolSpecInputSchema", () => {
it("should convert ClineToolSpec to Anthropic Tool", () => {
const result = toolSpecInputSchema(mockToolSpec, baseContext)
expect(result).to.have.property("name", "test_tool")
expect(result).to.have.property("description", "A test tool for unit testing")
expect(result).to.have.nested.property("input_schema.type", "object")
expect(result).to.have.nested.property("input_schema.properties.param1")
expect(result).to.have.nested.property("input_schema.properties.param2")
expect(result.input_schema.required).to.deep.equal(["param1"])
})
it("should handle tool without parameters", () => {
const toolWithoutParams: ClineToolSpec = {
...mockToolSpec,
parameters: undefined,
}
const result = toolSpecInputSchema(toolWithoutParams, baseContext)
expect(result.input_schema.properties).to.deep.equal({})
expect(result.input_schema.required).to.deep.equal([])
})
it("should throw error when context requirements are not met", () => {
const toolWithContextReq: ClineToolSpec = {
...mockToolSpec,
contextRequirements: () => false,
}
expect(() => toolSpecInputSchema(toolWithContextReq, baseContext)).to.throw(
"Tool test_tool does not meet context requirements",
)
})
})
describe("toolSpecFunctionDeclarations", () => {
it("should convert ClineToolSpec to Google Tool", () => {
const result = toolSpecFunctionDeclarations(mockToolSpec, baseContext)
expect(result).to.have.property("name", "test_tool")
expect(result).to.have.property("description", "A test tool for unit testing")
expect(result).to.have.nested.property("parameters.type", "OBJECT")
expect(result).to.have.nested.property("parameters.properties.param1")
expect(result).to.have.nested.property("parameters.properties.param2")
expect(result.parameters?.required).to.deep.equal(["param1"])
})
it("should map parameter types to Google types", () => {
const toolWithVariousTypes: ClineToolSpec = {
...mockToolSpec,
parameters: [
{
name: "str_param",
required: true,
instruction: "String param",
type: "string",
},
{
name: "num_param",
required: false,
instruction: "Number param",
type: "integer",
},
{
name: "bool_param",
required: false,
instruction: "Boolean param",
type: "boolean",
},
{
name: "obj_param",
required: false,
instruction: "Object param",
type: "object",
},
],
}
const result = toolSpecFunctionDeclarations(toolWithVariousTypes, baseContext)
expect(result.parameters?.properties?.str_param).to.have.property("type", "STRING")
expect(result.parameters?.properties?.num_param).to.have.property("type", "NUMBER")
expect(result.parameters?.properties?.bool_param).to.have.property("type", "BOOLEAN")
expect(result.parameters?.properties?.obj_param).to.have.property("type", "OBJECT")
})
it("should skip parameters without names", () => {
const toolWithUnnamedParam: ClineToolSpec = {
...mockToolSpec,
parameters: [
{
name: "",
required: true,
instruction: "Unnamed param",
type: "string",
},
{
name: "valid_param",
required: true,
instruction: "Valid param",
type: "string",
},
],
}
const result = toolSpecFunctionDeclarations(toolWithUnnamedParam, baseContext)
expect(result.parameters?.properties).to.not.have.property("")
expect(result.parameters?.properties).to.have.property("valid_param")
})
it("should skip $schema property in nested properties", () => {
const toolWithSchema: ClineToolSpec = {
...mockToolSpec,
parameters: [
{
name: "config",
required: true,
instruction: "Config object",
type: "object",
properties: {
$schema: { type: "string" },
validProp: { type: "string" },
},
},
],
}
const result = toolSpecFunctionDeclarations(toolWithSchema, baseContext)
expect(result.parameters?.properties?.config.properties).to.not.have.property("$schema")
expect(result.parameters?.properties?.config.properties).to.have.property("validProp")
})
it("should handle enum values in nested properties", () => {
const toolWithEnum: ClineToolSpec = {
...mockToolSpec,
parameters: [
{
name: "config",
required: true,
instruction: "Config object",
type: "object",
properties: {
status: {
type: "string",
enum: ["active", "inactive"],
},
},
},
],
}
const result = toolSpecFunctionDeclarations(toolWithEnum, baseContext)
expect(result.parameters?.properties?.config.properties?.status).to.have.property("enum")
expect(result.parameters?.properties?.config.properties?.status.enum).to.deep.equal(["active", "inactive"])
})
})
describe("openAIToolToAnthropic", () => {
it("should convert OpenAI function tool to Anthropic format", () => {
const openAITool: ChatCompletionTool = {
type: "function" as const,
function: {
name: "test_function",
description: "Test function",
parameters: {
type: "object" as const,
properties: {
param1: { type: "string" },
},
required: ["param1"],
},
},
}
const result = openAIToolToAnthropic(openAITool)
expect(result).to.have.property("name", "test_function")
expect(result).to.have.property("description", "Test function")
expect(result).to.have.nested.property("input_schema.type", "object")
expect(result).to.have.nested.property("input_schema.properties.param1")
expect(result.input_schema.required).to.deep.equal(["param1"])
})
it("should handle missing description in OpenAI tool", () => {
const openAITool: ChatCompletionTool = {
type: "function" as const,
function: {
name: "test_function",
parameters: {
type: "object" as const,
properties: {},
},
},
}
const result = openAIToolToAnthropic(openAITool)
expect(result).to.have.property("description", "")
})
it("should handle missing parameters in OpenAI tool", () => {
const openAITool: ChatCompletionTool = {
type: "function" as const,
function: {
name: "test_function",
description: "Test function",
},
}
const result = openAIToolToAnthropic(openAITool)
expect(result.input_schema.properties).to.deep.equal({})
expect(result.input_schema.required).to.deep.equal([])
})
it("should convert custom tool with text format", () => {
const openAITool = {
type: "custom" as const,
custom: {
name: "custom_tool",
description: "Custom tool",
format: {
type: "text" as const,
},
},
}
const result = openAIToolToAnthropic(openAITool as any)
expect(result).to.have.property("name", "custom_tool")
expect(result).to.have.property("description", "Custom tool")
expect(result).to.have.nested.property("input_schema.properties.text")
})
it("should convert custom tool with non-text format", () => {
const openAITool = {
type: "custom" as const,
custom: {
name: "custom_tool",
description: "Custom tool",
format: {
type: "json" as const,
},
},
}
const result = openAIToolToAnthropic(openAITool as any)
expect(result).to.have.nested.property("input_schema.properties.grammar")
})
})
describe("toOpenAIResponseTools", () => {
it("should convert array of OpenAI tools to Response API format", () => {
const openAITools: ChatCompletionTool[] = [
{
type: "function" as const,
function: {
name: "tool1",
description: "First tool",
parameters: {
type: "object" as const,
properties: { param1: { type: "string" } },
},
strict: false,
},
},
{
type: "function" as const,
function: {
name: "tool2",
description: "Second tool",
parameters: {
type: "object" as const,
properties: { param2: { type: "number" } },
},
},
},
]
const result = toOpenAIResponseTools(openAITools)
expect(result).to.have.length(2)
expect(result[0]).to.have.property("name", "tool1")
expect(result[0]).to.have.property("strict", false)
expect(result[1]).to.have.property("name", "tool2")
expect(result[1]).to.have.property("strict", true) // Default to true
})
it("should filter out non-function tools", () => {
const openAITools: ChatCompletionTool[] = [
{
type: "function" as const,
function: {
name: "tool1",
description: "First tool",
parameters: {
type: "object" as const,
properties: {},
},
},
},
]
const result = toOpenAIResponseTools(openAITools)
expect(result).to.have.length(1)
expect(result[0]).to.have.property("name", "tool1")
})
it("should handle null or undefined input", () => {
const result1 = toOpenAIResponseTools(null as any)
const result2 = toOpenAIResponseTools(undefined as any)
expect(result1).to.deep.equal([])
expect(result2).to.deep.equal([])
})
it("should handle empty array", () => {
const result = toOpenAIResponseTools([])
expect(result).to.deep.equal([])
})
})
describe("toOpenAIResponsesAPITool", () => {
it("should convert OpenAI function tool to Response API format", () => {
const openAITool: ChatCompletionTool = {
type: "function" as const,
function: {
name: "test_tool",
description: "Test tool",
strict: true,
parameters: {
type: "object" as const,
properties: {
param1: { type: "string" },
},
required: ["param1"],
},
},
}
const result = toOpenAIResponsesAPITool(openAITool)
expect(result).to.have.property("type", "function")
expect(result).to.have.property("name", "test_tool")
expect(result).to.have.property("description", "Test tool")
expect(result).to.have.property("strict", true)
if (result.type === "function" && result.parameters) {
expect(result.parameters).to.have.property("type", "object")
expect(result.parameters).to.have.nested.property("properties.param1")
expect(result.parameters.required).to.deep.equal(["param1"])
}
})
it("should default strict to false if not provided", () => {
const openAITool: ChatCompletionTool = {
type: "function" as const,
function: {
name: "test_tool",
description: "Test tool",
parameters: {
type: "object" as const,
properties: {},
},
},
}
const result = toOpenAIResponsesAPITool(openAITool)
expect(result).to.have.property("strict", false)
})
it("should handle missing description", () => {
const openAITool: ChatCompletionTool = {
type: "function" as const,
function: {
name: "test_tool",
parameters: {
type: "object" as const,
properties: {},
},
},
}
const result = toOpenAIResponsesAPITool(openAITool)
expect(result).to.have.property("description", "")
})
it("should convert custom tool with text format", () => {
const openAITool = {
type: "custom" as const,
custom: {
name: "custom_tool",
description: "Custom tool",
format: {
type: "text" as const,
},
},
}
const result = toOpenAIResponsesAPITool(openAITool as any)
expect(result).to.have.property("type", "function")
expect(result).to.have.property("name", "custom_tool")
expect(result).to.have.property("strict", false)
if (result.type === "function" && result.parameters) {
expect(result.parameters).to.have.nested.property("properties.text")
expect(result.parameters.required).to.deep.equal(["text"])
}
})
it("should convert custom tool with non-text format", () => {
const openAITool = {
type: "custom" as const,
custom: {
name: "custom_tool",
description: "Custom tool",
format: {
type: "grammar" as const,
},
},
}
const result = toOpenAIResponsesAPITool(openAITool as any)
if (result.type === "function" && result.parameters) {
expect(result.parameters).to.have.nested.property("properties.grammar")
expect(result.parameters.required).to.deep.equal(["text"])
}
})
})
})
+68 -29
View File
@@ -1,6 +1,7 @@
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { FunctionDeclaration as GoogleTool, Type as GoogleToolParamType } from "@google/genai"
import { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { FunctionTool as OpenAIResponseFunctionTool, Tool as OpenAIResponseTool } from "openai/resources/responses/responses"
import { ModelFamily } from "@/shared/prompts"
import type { ClineDefaultTool } from "@/shared/tools"
import type { SystemPromptContext } from "./types"
@@ -308,55 +309,93 @@ export function toolSpecFunctionDeclarations(tool: ClineToolSpec, context: Syste
* Converts an OpenAI ChatCompletionTool into an Anthropic Tool definition
*/
export function openAIToolToAnthropic(openAITool: OpenAITool): AnthropicTool {
const func = openAITool.function
if (openAITool.type === "function") {
const func = openAITool.function
return {
name: func.name,
description: func.description || "",
input_schema: {
type: "object",
properties: func.parameters?.properties || {},
required: func.parameters?.required || [],
},
}
}
return {
name: func.name,
description: func.description || "",
name: openAITool.custom.name,
description: openAITool.custom.description || "",
input_schema: {
type: "object",
properties: func.parameters?.properties || {},
required: func.parameters?.required || [],
required: true,
properties:
openAITool.custom.format?.type === "text" ? { text: { type: "string" } } : { grammar: { type: "object" } },
},
}
}
type OpenAIResponseTool = {
type: "function"
function: {
name: string
description: string
strict: boolean
parameters: {
type: "object"
properties: Record<string, any>
required: string[]
additionalProperties?: boolean
}
/**
* Converts OpenAI tools to Response API format.
* Filters for function-type tools and applies Response API defaults.
*/
export function toOpenAIResponseTools(openAITools: OpenAITool[]): OpenAIResponseTool[] {
if (!openAITools) {
return []
}
return openAITools
.filter((tool): tool is OpenAITool & { type: "function" } => tool.type === "function")
.map((tool) => ({
type: "function" as const,
name: tool.function.name,
description: tool.function.description,
parameters: (tool.function.parameters as { [key: string]: unknown } | null) ?? null,
strict: tool.function.strict ?? true,
}))
}
/**
* Converts an OpenAI ChatCompletionTool into Response API format.
*/
export function toOpenAIResponsesAPITool(openAITool: OpenAITool): OpenAIResponseTool {
return {
type: "function",
function: {
name: openAITool.function.name,
description: openAITool.function.description || "",
strict: openAITool.function.strict || false,
if (openAITool.type === "function") {
const fn = openAITool.function
return {
type: "function",
name: fn.name,
description: fn.description || "",
strict: fn.strict || false,
parameters: {
type: "object",
properties: openAITool.function.parameters?.properties || {},
required: openAITool.function.parameters?.required ? (openAITool.function.parameters?.required as string[]) : [],
properties: fn.parameters?.properties || {},
required: (fn.parameters?.required as string[]) || [],
},
} satisfies OpenAIResponseFunctionTool
}
// Handle custom tool type
const custom = openAITool.custom
const isTextFormat = custom.format?.type === "text"
return {
type: "function",
name: custom.name,
description: custom.description || "",
strict: false,
parameters: {
type: "object",
properties: isTextFormat ? { text: { type: "string" } } : { grammar: { type: "object" } },
required: ["text"],
},
} satisfies OpenAIResponseTool
}
function replacer(description: string, context: SystemPromptContext) {
return description
.replace("{{BROWSER_VIEWPORT_WIDTH}}", `${context.browserSettings?.viewport?.width || 900}`)
.replace("{{BROWSER_VIEWPORT_HEIGHT}}", `${context.browserSettings?.viewport?.height || 600}`)
/**
* Replaces template placeholders in description with viewport dimensions.
*/
function replacer(description: string, context: SystemPromptContext): string {
const width = context.browserSettings?.viewport?.width || 900
const height = context.browserSettings?.viewport?.height || 600
return description.replace("{{BROWSER_VIEWPORT_WIDTH}}", String(width)).replace("{{BROWSER_VIEWPORT_HEIGHT}}", String(height))
}
@@ -46,9 +46,9 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
ClineDefaultTool.BASH,
ClineDefaultTool.FILE_READ,
// Should disable FILE_NEW and FILE_EDIT when enabled
// ClineDefaultTool.APPLY_PATCH,
ClineDefaultTool.FILE_NEW, // Replaced by APPLY_PATCH
ClineDefaultTool.FILE_EDIT, // Replaced by APPLY_PATCH
ClineDefaultTool.APPLY_PATCH,
// ClineDefaultTool.FILE_NEW, // Replaced by APPLY_PATCH
// ClineDefaultTool.FILE_EDIT, // Replaced by APPLY_PATCH
ClineDefaultTool.SEARCH,
ClineDefaultTool.LIST_FILES,
ClineDefaultTool.LIST_CODE_DEF,
@@ -1,8 +1,12 @@
import { Anthropic } from "@anthropic-ai/sdk"
import type { ToolUse } from "@core/assistant-message"
import { JSONParser } from "@streamparser/json"
import { McpHub } from "@/services/mcp/McpHub"
import { CLINE_MCP_TOOL_IDENTIFIER } from "@/shared/mcp"
import {
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
ClineAssistantToolUseBlock,
} from "@/shared/messages/content"
import { ClineDefaultTool } from "@/shared/tools"
export interface PendingToolUse {
@@ -21,6 +25,22 @@ interface ToolUseDeltaBlock {
input?: string
}
export interface ReasoningDelta {
id?: string
reasoning?: string
signature?: string
details?: any[]
redacted_data?: any
}
export interface PendingReasoning {
id: string
content: string
signature: string
details: any[]
redactedThinking: ClineAssistantRedactedThinkingBlock[]
}
const ESCAPE_MAP: Record<string, string> = {
"\\n": "\n",
"\\t": "\t",
@@ -31,10 +51,39 @@ const ESCAPE_MAP: Record<string, string> = {
const ESCAPE_PATTERN = /\\[ntr"\\]/g
export class StreamResponseHandler {
private toolUseHandler = new ToolUseHandler()
private reasoningHandler = new ReasoningHandler()
private _requestId: string | undefined
public setRequestId(id?: string) {
if (!this._requestId && id) {
this._requestId = id
}
}
public get requestId() {
return this._requestId
}
public getHandlers() {
return {
toolUseHandler: this.toolUseHandler,
reasonsHandler: this.reasoningHandler,
}
}
public reset() {
this.toolUseHandler = new ToolUseHandler()
this.reasoningHandler = new ReasoningHandler()
}
}
/**
* Handles streaming native tool use blocks and converts them to Anthropic.ToolUseBlockParam format
* Handles streaming native tool use blocks and converts them to ClineAssistantToolUseBlock format
*/
export class ToolUseHandler {
class ToolUseHandler {
private pendingToolUses = new Map<string, PendingToolUse>()
processToolUseDelta(delta: ToolUseDeltaBlock, call_id?: string): void {
@@ -60,7 +109,7 @@ export class ToolUseHandler {
}
}
getFinalizedToolUse(id: string): Anthropic.ToolUseBlockParam | undefined {
getFinalizedToolUse(id: string): ClineAssistantToolUseBlock | undefined {
const pending = this.pendingToolUses.get(id)
if (!pending?.name) {
return undefined
@@ -82,11 +131,12 @@ export class ToolUseHandler {
id: pending.id,
name: pending.name,
input,
call_id: pending.call_id,
}
}
getAllFinalizedToolUses(): Anthropic.ToolUseBlockParam[] {
const results: Anthropic.ToolUseBlockParam[] = []
getAllFinalizedToolUses(): ClineAssistantToolUseBlock[] {
const results: ClineAssistantToolUseBlock[] = []
for (const id of this.pendingToolUses.keys()) {
const toolUse = this.getFinalizedToolUse(id)
if (toolUse) {
@@ -131,6 +181,7 @@ export class ToolUseHandler {
},
partial: true,
isNativeToolCall: true,
call_id: pending.call_id,
})
} else {
const params: Record<string, string> = {}
@@ -145,6 +196,7 @@ export class ToolUseHandler {
params: params as any,
partial: true,
isNativeToolCall: true,
call_id: pending.call_id,
})
}
}
@@ -190,3 +242,83 @@ export class ToolUseHandler {
return result
}
}
/**
* Handles streaming reasoning content and converts it to the appropriate message format
*/
class ReasoningHandler {
private pendingReasoning: PendingReasoning | null = null
processReasoningDelta(delta: ReasoningDelta): void {
if (!delta.id && !this.pendingReasoning) {
return
}
// Initialize pending reasoning if we have an ID but no pending reasoning yet
if (delta.id && !this.pendingReasoning) {
this.pendingReasoning = {
id: delta.id,
content: "",
signature: "",
details: [],
redactedThinking: [],
}
}
if (!this.pendingReasoning) {
return
}
// Update fields from delta
if (delta.reasoning) {
this.pendingReasoning.content += delta.reasoning
}
if (delta.signature) {
this.pendingReasoning.signature = delta.signature
}
if (delta.details) {
if (Array.isArray(delta.details)) {
this.pendingReasoning.details.push(...delta.details)
} else {
this.pendingReasoning.details.push(delta.details)
}
}
if (delta.redacted_data) {
this.pendingReasoning.redactedThinking.push({
type: "redacted_thinking",
data: delta.redacted_data,
call_id: delta.id || this.pendingReasoning.id,
})
}
}
getCurrentReasoning(): { content: string; details: any[]; redactedThinking: ClineAssistantRedactedThinkingBlock[] } | null {
if (!this.pendingReasoning) {
return null
}
return {
content: this.pendingReasoning.content,
details: this.pendingReasoning.details,
redactedThinking: this.pendingReasoning.redactedThinking,
}
}
getThinkingBlock(): ClineAssistantThinkingBlock | null {
if (!this.pendingReasoning) {
return null
}
return {
type: "thinking",
thinking: this.pendingReasoning.content,
signature: this.pendingReasoning.signature,
}
}
getRedactedThinking(): ClineAssistantRedactedThinkingBlock[] {
return this.pendingReasoning?.redactedThinking || []
}
reset(): void {
this.pendingReasoning = null
}
}
+161 -87
View File
@@ -1,5 +1,4 @@
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, ApiProviderInfo, buildApiHandler } from "@core/api"
import { ApiStream } from "@core/api/transform/stream"
import { AssistantMessageContent, parseAssistantMessageV2 } from "@core/assistant-message"
@@ -67,7 +66,13 @@ import { CLINE_MCP_TOOL_IDENTIFIER } from "@shared/mcp"
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
import { ClineDefaultTool } from "@shared/tools"
import { ClineAskResponse } from "@shared/WebviewMessage"
import { isClaude4PlusModelFamily, isGPT5ModelFamily, isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
import {
isClaude4PlusModelFamily,
isGPT5ModelFamily,
isLocalModel,
isNextGenModelFamily,
isNextGenModelProvider,
} from "@utils/model-utils"
import { arePathsEqual, getDesktopDir } from "@utils/path"
import { filterExistingFiles } from "@utils/tabFiltering"
import cloneDeep from "clone-deep"
@@ -76,13 +81,22 @@ import pWaitFor from "p-wait-for"
import * as path from "path"
import { ulid } from "ulid"
import * as vscode from "vscode"
import { ToolUseHandler } from "@/core/api/transform/tool-use-handler"
import type { SystemPromptContext } from "@/core/prompts/system-prompt"
import { getSystemPrompt } from "@/core/prompts/system-prompt"
import { HostProvider } from "@/hosts/host-provider"
import { isSubagentCommand, transformClineCommand } from "@/integrations/cli-subagents/subagent_command"
import { ClineError, ClineErrorType, ErrorService } from "@/services/error"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@/services/telemetry"
import {
ClineAssistantContent,
ClineAssistantRedactedThinkingBlock,
ClineContent,
ClineImageContentBlock,
ClineMessageModelInfo,
ClineStorageMessage,
ClineToolResponseContent,
ClineUserContent,
} from "@/shared/messages/content"
import { ShowMessageType } from "@/shared/proto/index.host"
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
import { isInTestMode } from "../../services/test/TestMode"
@@ -92,12 +106,11 @@ import { Controller } from "../controller"
import { StateManager } from "../storage/StateManager"
import { FocusChainManager } from "./focus-chain"
import { MessageStateHandler } from "./message-state"
import { StreamResponseHandler } from "./StreamHandler"
import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
import { detectAvailableCliTools, extractProviderDomainFromUrl, updateApiReqMsg } from "./utils"
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.ContentBlockParam>
export type ToolResponse = ClineToolResponseContent
type TaskParams = {
controller: Controller
@@ -204,7 +217,7 @@ export class Task {
* because of the expected format from the tool calls is different.
*/
private useNativeToolCalls: boolean = false
private toolUseHandler: ToolUseHandler
private streamHandler: StreamResponseHandler
private terminalExecutionMode: "vscodeTerminal" | "backgroundExec"
private activeBackgroundCommand?: {
@@ -318,7 +331,7 @@ export class Task {
this.browserSession = new BrowserSession(stateManager)
this.contextManager = new ContextManager()
this.diffViewProvider = HostProvider.get().createDiffViewProvider()
this.toolUseHandler = new ToolUseHandler()
this.streamHandler = new StreamResponseHandler()
this.cwd = cwd
this.stateManager = stateManager
this.workspaceManager = workspaceManager
@@ -453,10 +466,6 @@ export class Task {
await this.postStateToWebview().catch((e) =>
console.error("Error posting state to webview in onRetryAttempt:", e),
)
console.log(
`[Task ${this.taskId}] API Auto-Retry Status Update: Attempt ${attempt}/${maxRetries}, Delay: ${delay}ms`,
)
} catch (e) {
console.error(`[Task ${this.taskId}] Error updating api_req_started with retryStatus:`, e)
}
@@ -697,6 +706,12 @@ export class Task {
throw new Error("Cline instance aborted")
}
const providerInfo = this.getCurrentProviderInfo()
const modelInfo: ClineMessageModelInfo = {
providerId: providerInfo.providerId,
modelId: providerInfo.model.id,
}
if (partial !== undefined) {
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
const isUpdatingPreviousPartial =
@@ -723,6 +738,7 @@ export class Task {
images,
files,
partial,
modelInfo,
})
await this.postStateToWebview()
return sayTs
@@ -755,6 +771,7 @@ export class Task {
text,
images,
files,
modelInfo,
})
await this.postStateToWebview()
return sayTs
@@ -771,6 +788,7 @@ export class Task {
text,
images,
files,
modelInfo,
})
await this.postStateToWebview()
return sayTs
@@ -805,7 +823,7 @@ export class Task {
}
private async runUserPromptSubmitHook(
userContent: UserContent,
userContent: ClineContent[],
_context: "initial_task" | "resume" | "feedback",
): Promise<{ cancel?: boolean; contextModification?: string; errorMessage?: string }> {
const hooksEnabled = this.stateManager.getGlobalSettingsKey("hooksEnabled")
@@ -883,9 +901,9 @@ export class Task {
this.taskState.isInitialized = true
const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
const imageBlocks: ClineImageContentBlock[] = formatResponse.imageBlocks(images)
const userContent: UserContent = [
const userContent: ClineUserContent[] = [
{
type: "text",
text: `<task>\n${task}\n</task>`,
@@ -932,7 +950,6 @@ export class Task {
if (taskStartResult.cancel === true) {
// If hook was cancelled by user, save state for resume
if (taskStartResult.wasCancelled) {
console.log(`[TaskStart Hook] User cancelled, saving messages for task ${this.taskId}`)
// Set flag to allow Controller.cancelTask() to proceed
this.taskState.didFinishAbortingStream = true
// Save BOTH clineMessages AND apiConversationHistory so Controller.cancelTask() can find the task
@@ -941,7 +958,6 @@ export class Task {
this.messageStateHandler.getApiConversationHistory(),
)
await this.postStateToWebview()
console.log(`[TaskStart Hook] Messages saved successfully, returning from hook`)
}
// abortTask will handle cleanup
@@ -1048,7 +1064,7 @@ export class Task {
const { response, text, images, files } = await this.ask(askType) // calls poststatetowebview
// Initialize newUserContent array for hook context
const newUserContent: UserContent = []
const newUserContent: ClineContent[] = []
// Run TaskResume hook AFTER user clicks resume button
const hooksEnabled = this.stateManager.getGlobalSettingsKey("hooksEnabled")
@@ -1124,15 +1140,15 @@ export class Task {
const existingApiConversationHistory = this.messageStateHandler.getApiConversationHistory()
// Remove the last user message so we can update it with the resume message
let modifiedOldUserContent: UserContent // either the last message if its user message, or the user message before the last (assistant) message
let modifiedApiConversationHistory: Anthropic.Messages.MessageParam[] // need to remove the last user message to replace with new modified user message
let modifiedOldUserContent: ClineContent[] // either the last message if its user message, or the user message before the last (assistant) message
let modifiedApiConversationHistory: ClineStorageMessage[] // need to remove the last user message to replace with new modified user message
if (existingApiConversationHistory.length > 0) {
const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
if (lastMessage.role === "assistant") {
modifiedApiConversationHistory = [...existingApiConversationHistory]
modifiedOldUserContent = []
} else if (lastMessage.role === "user") {
const existingUserContent: UserContent = Array.isArray(lastMessage.content)
const existingUserContent: ClineContent[] = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text", text: lastMessage.content }]
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
@@ -1250,7 +1266,7 @@ export class Task {
await this.initiateTaskLoop(newUserContent)
}
private async initiateTaskLoop(userContent: UserContent): Promise<void> {
private async initiateTaskLoop(userContent: ClineContent[]): Promise<void> {
let nextUserContent = userContent
let includeFileDetails = true
while (!this.taskState.abort) {
@@ -1478,7 +1494,7 @@ export class Task {
}
// Tools
async executeCommandTool(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ToolResponse]> {
async executeCommandTool(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ClineToolResponseContent]> {
// For Cline CLI subagents, we want to parse and process the command to ensure flags are correct
const isSubagent = isSubagentCommand(command)
@@ -2010,6 +2026,10 @@ export class Task {
maxConsecutiveMistakes: this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes"),
})
const nativeToolCallsGloballyEnabled =
featureFlagsService.getNativeToolCallEnabled() && this.stateManager.getGlobalStateKey("nativeToolCallEnabled")
const inferredNativeToolCalls =
!nativeToolCallsGloballyEnabled && isNextGenModelProvider(providerInfo) && isNextGenModelFamily(providerInfo.model.id)
const promptContext: SystemPromptContext = {
cwd: this.cwd,
ide,
@@ -2030,8 +2050,7 @@ export class Task {
workspaceRoots,
isSubagentsEnabledAndCliInstalled,
isCliSubagent,
enableNativeToolCalls:
featureFlagsService.getNativeToolCallEnabled() && this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
enableNativeToolCalls: nativeToolCallsGloballyEnabled || inferredNativeToolCalls,
}
const { systemPrompt, tools } = await getSystemPrompt(promptContext)
@@ -2339,7 +2358,7 @@ export class Task {
}
}
async recursivelyMakeClineRequests(userContent: UserContent, includeFileDetails: boolean = false): Promise<boolean> {
async recursivelyMakeClineRequests(userContent: ClineContent[], includeFileDetails: boolean = false): Promise<boolean> {
// Check abort flag at the very start to prevent any execution after cancellation
if (this.taskState.abort) {
throw new Error("Task instance aborted")
@@ -2361,6 +2380,11 @@ export class Task {
} catch {}
}
const modelInfo = {
modelId: model.id,
providerId: providerId,
}
if (this.taskState.consecutiveMistakeCount >= this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")) {
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
if (autoApprovalSettings.enableNotifications) {
@@ -2380,7 +2404,7 @@ export class Task {
await this.say("user_feedback", text, images, files)
// This userContent is for the *next* API call.
const feedbackUserContent: UserContent = []
const feedbackUserContent: ClineUserContent[] = []
feedbackUserContent.push({
type: "text",
text: formatResponse.tooManyMistakes(text),
@@ -2524,7 +2548,7 @@ export class Task {
}
}
let parsedUserContent: UserContent
let parsedUserContent: ClineContent[]
let environmentDetails: string
let clinerulesError: boolean
@@ -2681,7 +2705,7 @@ export class Task {
telemetryService.captureConversationTurnEvent(
this.ulid,
providerId,
this.api.getModel().id,
modelInfo.modelId,
"assistant",
currentMode,
{
@@ -2691,6 +2715,7 @@ export class Task {
cacheReadTokens,
totalCost,
},
this.useNativeToolCalls, // For assistant turn only.
)
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
@@ -2709,17 +2734,22 @@ export class Task {
this.taskState.presentAssistantMessageHasPendingUpdates = false
this.taskState.didAutomaticallyRetryFailedApiRequest = false
await this.diffViewProvider.reset()
this.toolUseHandler.reset()
this.streamHandler.reset()
this.taskState.toolUseIdMap.clear()
const { toolUseHandler, reasonsHandler } = this.streamHandler.getHandlers()
const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk)
let assistantMessage = "" // For UI display (includes XML)
let assistantTextOnly = "" // For API history (text only, no tool XML)
let reasoningMessage = ""
const reasoningDetails = []
const antThinkingContent: (Anthropic.Messages.RedactedThinkingBlock | Anthropic.Messages.ThinkingBlock)[] = []
const redactedThinkingContent: ClineAssistantRedactedThinkingBlock[] = []
this.taskState.isStreaming = true
let didReceiveUsageChunk = false
const reasoningSignature = ""
let reasoningID = ""
try {
for await (const chunk of stream) {
if (!chunk) {
@@ -2727,6 +2757,8 @@ export class Task {
}
switch (chunk.type) {
case "usage":
this.streamHandler.setRequestId(chunk.id)
didReceiveUsageChunk = true
inputTokens += chunk.inputTokens
outputTokens += chunk.outputTokens
@@ -2734,50 +2766,61 @@ export class Task {
cacheReadTokens += chunk.cacheReadTokens ?? 0
totalCost = chunk.totalCost
break
case "reasoning":
// reasoning will always come before assistant message
reasoningMessage += chunk.reasoning
case "reasoning": {
// Process the reasoning delta through the handler
// Ensure details is always an array
const details = chunk.details ? (Array.isArray(chunk.details) ? chunk.details : [chunk.details]) : []
reasonsHandler.processReasoningDelta({
id: chunk.id,
reasoning: chunk.reasoning,
signature: chunk.signature,
details,
redacted_data: chunk.redacted_data,
})
// Capture reasoning ID for use when storing the message
if (chunk.id) {
reasoningID = chunk.id
}
// Get the current reasoning state
const reasoningState = reasonsHandler.getCurrentReasoning()
if (reasoningState) {
reasoningMessage = reasoningState.content
reasoningDetails.push(...reasoningState.details)
}
// fixes bug where cancelling task > aborts task > for loop may be in middle of streaming reasoning > say function throws error before we get a chance to properly clean up and cancel the task.
if (!this.taskState.abort) {
await this.say("reasoning", reasoningMessage, undefined, undefined, true)
const thinkingBlock = reasonsHandler.getThinkingBlock()
if (thinkingBlock) {
await this.say("reasoning", thinkingBlock.thinking, undefined, undefined, true)
}
}
break
// for cline/openrouter providers
case "reasoning_details":
// reasoning_details may be an array of 0 or 1 items depending on how openrouter returns it
if (Array.isArray(chunk.reasoning_details)) {
reasoningDetails.push(...chunk.reasoning_details)
} else {
reasoningDetails.push(chunk.reasoning_details)
// Get any redacted thinking content
const newRedactedThinking = reasonsHandler.getRedactedThinking()
if (newRedactedThinking.length > 0) {
redactedThinkingContent.push(...newRedactedThinking)
}
break
// for anthropic providers
case "ant_thinking":
antThinkingContent.push({
type: "thinking",
thinking: chunk.thinking,
signature: chunk.signature,
})
break
case "ant_redacted_thinking":
antThinkingContent.push({
type: "redacted_thinking",
data: chunk.data,
})
break
}
case "tool_calls": {
if (!chunk.tool_call) {
console.log("no tool call in chunk, skipping...", chunk)
break
}
// Accumulate tool use blocks in proper Anthropic format
this.toolUseHandler.processToolUseDelta({
id: chunk.tool_call.function?.id,
type: "tool_use",
name: chunk.tool_call.function?.name,
input: chunk.tool_call.function?.arguments,
})
toolUseHandler.processToolUseDelta(
{
id: chunk.tool_call.function?.id,
type: "tool_use",
name: chunk.tool_call.function?.name,
input: chunk.tool_call.function?.arguments,
},
chunk.tool_call.call_id,
)
// Extract and store tool_use_id for creating proper ToolResultBlockParam
if (chunk.tool_call.function?.id && chunk.tool_call.function?.name) {
this.taskState.toolUseIdMap.set(chunk.tool_call.function.name, chunk.tool_call.function.id)
@@ -2796,7 +2839,7 @@ export class Task {
const textBlocks: AssistantMessageContent[] = textContent
? [{ type: "text", content: textContent, partial: false }]
: []
const toolBlocks = this.toolUseHandler.getPartialToolUsesAsContent()
const toolBlocks = toolUseHandler.getPartialToolUsesAsContent()
assistantMessage += toolBlocks.map((block) => JSON.stringify(block)).join("\n")
this.taskState.assistantMessageContent = [...textBlocks, ...toolBlocks]
@@ -2864,9 +2907,7 @@ export class Task {
: []
// Get all finalized tool uses and mark as complete
const toolBlocks = this.toolUseHandler
.getPartialToolUsesAsContent()
.map((block) => ({ ...block, partial: false }))
const toolBlocks = toolUseHandler.getPartialToolUsesAsContent().map((block) => ({ ...block, partial: false }))
this.taskState.assistantMessageContent = [...textBlocks, ...toolBlocks]
@@ -2991,38 +3032,66 @@ export class Task {
let didEndLoop = false
if (assistantMessage.length > 0 || this.useNativeToolCalls) {
const currentMode = this.stateManager.getGlobalSettingsKey("mode")
telemetryService.captureConversationTurnEvent(this.ulid, providerId, model.id, "assistant", currentMode, {
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
})
telemetryService.captureConversationTurnEvent(
this.ulid,
providerId,
model.id,
"assistant",
currentMode,
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
this.useNativeToolCalls,
)
const { reasonsHandler } = this.streamHandler.getHandlers()
const requestId = this.streamHandler.requestId
// Get finalized tool use blocks from the handler
const toolUseBlocks = this.toolUseHandler.getAllFinalizedToolUses()
const toolUseBlocks = toolUseHandler.getAllFinalizedToolUses()
// Build content array with thinking blocks, text (if any), and tool use blocks
const assistantContent: Array<
| Anthropic.Messages.RedactedThinkingBlock
| Anthropic.Messages.ThinkingBlock
| Anthropic.Messages.TextBlockParam
| Anthropic.ToolUseBlockParam
> = [
const assistantContent: Array<ClineAssistantContent> = [
// This is critical for maintaining the model's reasoning flow and conversation integrity.
// "When providing thinking blocks, the entire sequence of consecutive thinking blocks must match the outputs generated by the model during the original request; you cannot rearrange or modify the sequence of these blocks." The signature_delta is used to verify that the thinking was generated by Claude, and the thinking blocks will be ignored if it's incorrect or missing.
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#preserving-thinking-blocks
...antThinkingContent,
...redactedThinkingContent,
]
// Add thinking block from the reasoning handler if available
const thinkingBlock = reasonsHandler.getThinkingBlock()
if (thinkingBlock) {
assistantContent.push({
...thinkingBlock,
summary: reasoningDetails.length > 0 ? reasoningDetails : undefined,
call_id: reasoningID,
})
} else if (reasoningSignature || reasoningMessage || reasoningDetails.length) {
// Fallback to legacy reasoning handling if needed
assistantContent.push({
type: "thinking",
thinking: reasoningMessage,
signature: reasoningSignature,
summary: reasoningDetails.length > 0 ? reasoningDetails : undefined,
call_id: reasoningID,
})
}
// Get the current reasoning state to ensure we have the latest details
const currentReasoning = reasonsHandler.getCurrentReasoning()
const currentReasoningDetails = currentReasoning?.details || reasoningDetails
// Only add text block if there's actual text (not just tool XML)
if (assistantTextOnly.trim().length > 0) {
assistantContent.push({
type: "text",
text: assistantTextOnly,
// reasoning_details only exists for cline/openrouter providers
// @ts-ignore-next-line
reasoning_details: reasoningDetails.length > 0 ? reasoningDetails : undefined,
reasoning_details: currentReasoningDetails.length > 0 ? currentReasoningDetails : undefined,
})
}
@@ -3036,6 +3105,8 @@ export class Task {
await this.messageStateHandler.addToApiConversationHistory({
role: "assistant",
content: assistantContent,
modelInfo,
id: requestId,
})
}
@@ -3084,6 +3155,7 @@ export class Task {
provider: providerId,
errorMessage: "empty_assistant_message",
requestId: reqId,
isNativeToolCall: this.useNativeToolCalls,
})
const baseErrorMessage =
@@ -3099,6 +3171,8 @@ export class Task {
text: "Failure: I did not provide a response.",
},
],
modelInfo,
id: this.streamHandler.requestId,
})
let response: ClineAskResponse
@@ -3158,10 +3232,10 @@ export class Task {
}
async loadContext(
userContent: UserContent,
userContent: ClineContent[],
includeFileDetails: boolean = false,
useCompactPrompt = false,
): Promise<[UserContent, string, boolean]> {
): Promise<[ClineContent[], string, boolean]> {
// Track if we need to check clinerulesFile
let needsClinerulesFileCheck = false
+8 -6
View File
@@ -1,4 +1,3 @@
import Anthropic from "@anthropic-ai/sdk"
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import getFolderSize from "get-folder-size"
import Mutex from "p-mutex"
@@ -8,6 +7,7 @@ import { combineCommandSequences } from "@/shared/combineCommandSequences"
import { ClineMessage } from "@/shared/ExtensionMessage"
import { getApiMetrics } from "@/shared/getApiMetrics"
import { HistoryItem } from "@/shared/HistoryItem"
import { ClineStorageMessage } from "@/shared/messages/content"
import { getCwd, getDesktopDir } from "@/utils/path"
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
import { TaskState } from "./TaskState"
@@ -22,7 +22,7 @@ interface MessageStateHandlerParams {
}
export class MessageStateHandler {
private apiConversationHistory: Anthropic.MessageParam[] = []
private apiConversationHistory: ClineStorageMessage[] = []
private clineMessages: ClineMessage[] = []
private taskIsFavorited: boolean
private checkpointTracker: CheckpointTracker | undefined
@@ -58,11 +58,11 @@ export class MessageStateHandler {
return await this.stateMutex.withLock(fn)
}
getApiConversationHistory(): Anthropic.MessageParam[] {
getApiConversationHistory(): ClineStorageMessage[] {
return this.apiConversationHistory
}
setApiConversationHistory(newHistory: Anthropic.MessageParam[]): void {
setApiConversationHistory(newHistory: ClineStorageMessage[]): void {
this.apiConversationHistory = newHistory
}
@@ -93,6 +93,7 @@ export class MessageStateHandler {
(message) => !(message.ask === "resume_task" || message.ask === "resume_completed_task"),
)
]
const lastModelInfo = [...this.apiConversationHistory].reverse().find((msg) => msg.modelInfo !== undefined)
const taskDir = await ensureTaskDirectoryExists(this.taskId)
let taskDirSize = 0
try {
@@ -119,6 +120,7 @@ export class MessageStateHandler {
conversationHistoryDeletedRange: this.taskState.conversationHistoryDeletedRange,
isFavorited: this.taskIsFavorited,
checkpointManagerErrorMessage: this.taskState.checkpointManagerErrorMessage,
modelId: lastModelInfo?.modelInfo?.modelId,
})
} catch (error) {
console.error("Failed to save cline messages:", error)
@@ -135,7 +137,7 @@ export class MessageStateHandler {
})
}
async addToApiConversationHistory(message: Anthropic.MessageParam) {
async addToApiConversationHistory(message: ClineStorageMessage) {
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
return await this.withStateLock(async () => {
this.apiConversationHistory.push(message)
@@ -143,7 +145,7 @@ export class MessageStateHandler {
})
}
async overwriteApiConversationHistory(newHistory: Anthropic.MessageParam[]): Promise<void> {
async overwriteApiConversationHistory(newHistory: ClineStorageMessage[]): Promise<void> {
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
return await this.withStateLock(async () => {
this.apiConversationHistory = newHistory
+1 -1
View File
@@ -53,7 +53,7 @@ export class ToolResultUtils {
private static createToolResultBlock(content: ToolResponse, id?: string) {
// If id is "cline", we treat it as a plain text result for backward compatibility
// as we cannot find any existing tool call that matches this id.
if (id === "cline") {
if (id === "cline" || !id) {
return {
type: "text",
text: typeof content === "string" ? content : JSON.stringify(content, null, 2),
@@ -591,6 +591,7 @@ export class TelemetryService {
cacheReadTokens?: number
totalCost?: number
} = {},
isNativeToolCall?: boolean,
) {
// Ensure required parameters are provided
if (!ulid || !provider || !model || !source) {
@@ -608,6 +609,7 @@ export class TelemetryService {
mode,
timestamp: new Date().toISOString(), // Add timestamp for message sequencing
...tokenUsage,
isNativeToolCall,
},
})
}
@@ -1031,6 +1033,7 @@ export class TelemetryService {
provider?: string
errorStatus?: number | undefined
requestId?: string | undefined
isNativeToolCall?: boolean
}) {
this.capture({
event: TelemetryService.EVENTS.TASK.PROVIDER_API_ERROR,
+2
View File
@@ -12,6 +12,7 @@ import { DictationSettings } from "./DictationSettings"
import { FocusChainSettings } from "./FocusChainSettings"
import { HistoryItem } from "./HistoryItem"
import { McpDisplayMode } from "./McpDisplayMode"
import { ClineMessageModelInfo } from "./messages/content"
import { Mode, OpenaiReasoningEffort } from "./storage/types"
import { TelemetrySetting } from "./TelemetrySetting"
import { UserInfo } from "./UserInfo"
@@ -116,6 +117,7 @@ export interface ClineMessage {
isOperationOutsideWorkspace?: boolean
conversationHistoryIndex?: number
conversationHistoryDeletedRange?: [number, number] // for when conversation history is truncated for API requests
modelInfo?: ClineMessageModelInfo
}
export type ClineAsk =
+2
View File
@@ -15,4 +15,6 @@ export type HistoryItem = {
conversationHistoryDeletedRange?: [number, number]
isFavorited?: boolean
checkpointManagerErrorMessage?: string
modelId?: string
}
+45
View File
@@ -1352,6 +1352,51 @@ export const geminiModels = {
export type OpenAiNativeModelId = keyof typeof openAiNativeModels
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-5-2025-08-07"
export const openAiNativeModels = {
"gpt-5.1": {
maxTokens: 8_192, // 128000 breaks context window truncation
contextWindow: 272000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.25,
outputPrice: 10.0,
cacheReadsPrice: 0.125,
},
"gpt-5.1-codex": {
maxTokens: 8_192, // 128000 breaks context window truncation
contextWindow: 272000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.25,
outputPrice: 10.0,
cacheReadsPrice: 0.125,
},
"gpt-5.1-codex-mini": {
maxTokens: 8_192, // 128000 breaks context window truncation
contextWindow: 272000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.25,
outputPrice: 2.0,
cacheReadsPrice: 0.025,
},
"gpt-5": {
maxTokens: 8_192, // 128000 breaks context window truncation
contextWindow: 272000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.25,
outputPrice: 10.0,
cacheReadsPrice: 0.125,
},
"gpt-5-codex": {
maxTokens: 8_192, // 128000 breaks context window truncation
contextWindow: 272000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.25,
outputPrice: 10.0,
cacheReadsPrice: 0.125,
},
"gpt-5-2025-08-07": {
maxTokens: 8_192, // 128000 breaks context window truncation
contextWindow: 272000,
+127
View File
@@ -0,0 +1,127 @@
import { Anthropic } from "@anthropic-ai/sdk"
type ClinePromptInputContent = string
type ClineMessageRole = "user" | "assistant"
export interface ClineMessageModelInfo {
modelId: string
providerId: string
}
export interface ClineReasoningDetailParam {
type: "reasoning.text" | string
text: string
signature: string
format: "anthropic-claude-v1" | string
index: number
}
interface ClineSharedMessageParam {
// The id of the response that the block belongs to
call_id?: string
}
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"]
/**
* An extension of Anthropic.MessageParam that includes Cline-specific fields: reasoning_details.
* This ensures backward compatibility where the messages were stored in Anthropic format with addtional
* fields unknown to Anthropic SDK.
*/
export interface ClineTextContentBlock extends Anthropic.Messages.TextBlockParam {
// reasoning_details only exists for providers listed in REASONING_DETAILS_PROVIDERS
reasoning_details?: ClineReasoningDetailParam[]
}
export interface ClineImageContentBlock extends Anthropic.ImageBlockParam, ClineSharedMessageParam {}
export interface ClineDocumentContentBlock extends Anthropic.DocumentBlockParam, ClineSharedMessageParam {}
export interface ClineUserToolResultContentBlock extends Anthropic.ToolResultBlockParam, ClineSharedMessageParam {}
/**
* Assistant only content types
*/
export interface ClineAssistantToolUseBlock extends Anthropic.ToolUseBlockParam, ClineSharedMessageParam {}
export interface ClineAssistantThinkingBlock extends Anthropic.Messages.ThinkingBlock, ClineSharedMessageParam {
summary?: unknown[]
}
export interface ClineAssistantRedactedThinkingBlock
extends Anthropic.Messages.RedactedThinkingBlockParam,
ClineSharedMessageParam {}
export type ClineToolResponseContent = ClinePromptInputContent | Array<ClineTextContentBlock | ClineImageContentBlock>
export type ClineUserContent =
| ClineTextContentBlock
| ClineImageContentBlock
| ClineDocumentContentBlock
| ClineUserToolResultContentBlock
export type ClineAssistantContent =
| ClineTextContentBlock
| ClineImageContentBlock
| ClineDocumentContentBlock
| ClineAssistantToolUseBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
export type ClineContent = ClineUserContent | ClineAssistantContent
/**
* An extension of Anthropic.MessageParam that includes Cline-specific fields.
* This ensures backward compatibility where the messages were stored in Anthropic format,
* while allowing for additional metadata specific to Cline to avoid unknown fields in Anthropic SDK
* added by ignoring the type checking for those fields.
*/
export interface ClineStorageMessage extends Anthropic.MessageParam {
id?: string
role: ClineMessageRole
content: ClinePromptInputContent | ClineContent[]
/**
* NOTE: model information used when generating this message.
* Internal use for message conversion only.
* MUST be removed before sending message to any LLM provider.
*/
modelInfo?: ClineMessageModelInfo
}
/**
* Converts ClineStorageMessage to Anthropic.MessageParam by removing Cline-specific fields
* Cline-specific fields (like modelInfo, reasoning_details) are properly omitted.
*/
export function convertClineStorageToAnthropicMessage(
clineMessage: ClineStorageMessage,
provider = "anthropic",
): Anthropic.MessageParam {
const { role, content } = clineMessage
// Handle string content - fast path
if (typeof content === "string") {
return { role, content }
}
// Handle array content - strip Cline-specific fields for non-reasoning_details providers
const shouldCleanContent = !REASONING_DETAILS_PROVIDERS.includes(provider)
const cleanedContent = shouldCleanContent ? content.map(cleanContentBlock) : (content as Anthropic.MessageParam["content"])
return { role, content: cleanedContent }
}
/**
* Clean a content block by removing Cline-specific fields and returning only Anthropic-compatible fields
*/
export function cleanContentBlock(block: ClineContent): Anthropic.ContentBlock {
// Fast path: if no reasoning_details property exists, return as-is
if (!("reasoning_details" in block)) {
return block as Anthropic.ContentBlock
}
// Remove reasoning_details from text blocks
// biome-ignore lint/correctness/noUnusedVariables: intentional destructuring to remove property
const { reasoning_details, ...cleanBlock } = block as ClineTextContentBlock
return cleanBlock as Anthropic.ContentBlock
}
@@ -200,6 +200,7 @@ export function convertClineMessageToProto(message: AppClineMessage): ProtoCline
askQuestion: undefined,
askNewTask: undefined,
apiReqInfo: undefined,
modelInfo: message.modelInfo ?? undefined,
}
return protoMessage
@@ -629,6 +629,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
)}
</div>
)}
{item.modelId && <div className="text-description">Model: {item.modelId}</div>}
{!!item.totalCost && (
<div
style={{