mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 957dddb6a4 | |||
| d9558bf08e | |||
| b8e413bfd7 | |||
| adb3dcc616 | |||
| ed60898bcb | |||
| 775cf1a15d | |||
| 0de8692dc9 | |||
| 40c2e5341e | |||
| 8c09a18397 | |||
| 1d0e51aa97 | |||
| a5d50c9e04 |
Generated
+6
-20
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -15,4 +15,6 @@ export type HistoryItem = {
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
isFavorited?: boolean
|
||||
checkpointManagerErrorMessage?: string
|
||||
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user