mirror of
https://github.com/cline/cline.git
synced 2026-09-07 04:44:58 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdd2661382 | |||
| ad25d5ef44 | |||
| 18e0f8c079 | |||
| 1210b40738 | |||
| 30b2d8d6fa | |||
| 40af4058d8 | |||
| a3b37bb841 | |||
| e2e18606f4 | |||
| debfcce26a | |||
| 174260c77a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: Improve claude code error handling
|
||||
@@ -4,7 +4,7 @@ import { type ApiHandler } from ".."
|
||||
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
|
||||
import { withRetry } from "../retry"
|
||||
import { runClaudeCode } from "@/integrations/claude-code/run"
|
||||
import { ClaudeCodeMessage } from "@/integrations/claude-code/types"
|
||||
import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-filter"
|
||||
|
||||
export class ClaudeCodeHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
@@ -19,39 +19,16 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
maxDelay: 15000,
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// Filter out image blocks since Claude Code doesn't support them
|
||||
const filteredMessages = filterMessagesForClaudeCode(messages)
|
||||
|
||||
const claudeProcess = runClaudeCode({
|
||||
systemPrompt,
|
||||
messages,
|
||||
messages: filteredMessages,
|
||||
path: this.options.claudeCodePath,
|
||||
modelId: this.getModel().id,
|
||||
})
|
||||
|
||||
const dataQueue: string[] = []
|
||||
let processError = null
|
||||
let errorOutput = ""
|
||||
let exitCode: number | null = null
|
||||
|
||||
claudeProcess.stdout.on("data", (data) => {
|
||||
const output = data.toString()
|
||||
const lines = output.split("\n").filter((line: string) => line.trim() !== "")
|
||||
|
||||
for (const line of lines) {
|
||||
dataQueue.push(line)
|
||||
}
|
||||
})
|
||||
|
||||
claudeProcess.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString()
|
||||
})
|
||||
|
||||
claudeProcess.on("close", (code) => {
|
||||
exitCode = code
|
||||
})
|
||||
|
||||
claudeProcess.on("error", (error) => {
|
||||
processError = error
|
||||
})
|
||||
|
||||
// Usage is included with assistant messages,
|
||||
// but cost is included in the result chunk
|
||||
let usage: ApiStreamUsageChunk = {
|
||||
@@ -62,61 +39,75 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
|
||||
while (exitCode !== 0 || dataQueue.length > 0) {
|
||||
if (dataQueue.length === 0) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
let isPaidUsage = true
|
||||
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput.trim()}` : ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
const data = dataQueue.shift()
|
||||
if (!data) {
|
||||
continue
|
||||
}
|
||||
|
||||
const chunk = this.attemptParseChunk(data)
|
||||
|
||||
if (!chunk) {
|
||||
for await (const chunk of claudeProcess) {
|
||||
if (typeof chunk === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: data || "",
|
||||
text: chunk,
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "system" && chunk.subtype === "init") {
|
||||
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
|
||||
isPaidUsage = chunk.apiKeySource !== "none"
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "assistant" && "message" in chunk) {
|
||||
const message = chunk.message
|
||||
|
||||
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
|
||||
const errorMessage = message.content[0]?.text || `Claude Code stopped with reason: ${message.stop_reason}`
|
||||
if (message.stop_reason !== null) {
|
||||
const content = "text" in message.content[0] ? message.content[0] : undefined
|
||||
|
||||
if (errorMessage.includes("Invalid model name")) {
|
||||
throw new Error(
|
||||
errorMessage +
|
||||
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
|
||||
)
|
||||
const isError = content && content.text.startsWith(`API Error`)
|
||||
if (isError) {
|
||||
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
|
||||
const errorMessageStart = content.text.indexOf("{")
|
||||
const errorMessage = content.text.slice(errorMessageStart)
|
||||
|
||||
const error = this.attemptParse(errorMessage)
|
||||
if (!error) {
|
||||
throw new Error(content.text)
|
||||
}
|
||||
|
||||
if (error.error.message.includes("Invalid model name")) {
|
||||
throw new Error(
|
||||
content.text +
|
||||
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
}
|
||||
} else {
|
||||
console.warn("Unsupported content type:", content.type)
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
yield {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
}
|
||||
break
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: content.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
case "tool_use":
|
||||
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,14 +120,18 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.type === "result" && "result" in chunk) {
|
||||
usage.totalCost = chunk.cost_usd || 0
|
||||
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
|
||||
|
||||
yield usage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processError) {
|
||||
throw processError
|
||||
}
|
||||
private attemptParse(str: string) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,14 +147,4 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
info: claudeCodeModels[claudeCodeDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
// TOOD: Validate instead of parsing
|
||||
private attemptParseChunk(data: string): ClaudeCodeMessage | null {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (error) {
|
||||
console.error("Error parsing chunk:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/**
|
||||
* Filters out image blocks from messages since Claude Code doesn't support images.
|
||||
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
|
||||
*/
|
||||
export function filterMessagesForClaudeCode(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
|
||||
return messages.map((message) => {
|
||||
// Handle simple string messages
|
||||
if (typeof message.content === "string") {
|
||||
return message
|
||||
}
|
||||
|
||||
// Handle complex message structures
|
||||
const filteredContent = message.content.map((block) => {
|
||||
if (block.type === "image") {
|
||||
// Replace image blocks with text placeholders
|
||||
const sourceType = block.source?.type || "unknown"
|
||||
const mediaType = block.source?.media_type || "unknown"
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
|
||||
}
|
||||
}
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: filteredContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,23 +1,115 @@
|
||||
import * as vscode from "vscode"
|
||||
import Anthropic from "@anthropic-ai/sdk"
|
||||
import type Anthropic from "@anthropic-ai/sdk"
|
||||
import { execa } from "execa"
|
||||
import { ClaudeCodeMessage } from "./types"
|
||||
import readline from "readline"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
|
||||
export function runClaudeCode({
|
||||
systemPrompt,
|
||||
messages,
|
||||
path,
|
||||
modelId,
|
||||
}: {
|
||||
type ClaudeCodeOptions = {
|
||||
systemPrompt: string
|
||||
messages: Anthropic.Messages.MessageParam[]
|
||||
path?: string
|
||||
modelId?: string
|
||||
}) {
|
||||
}
|
||||
|
||||
type ProcessState = {
|
||||
partialData: string | null
|
||||
error: Error | null
|
||||
stderrLogs: string
|
||||
exitCode: number | null
|
||||
}
|
||||
|
||||
export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
const process = runProcess(options)
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdout,
|
||||
})
|
||||
|
||||
try {
|
||||
const processState: ProcessState = {
|
||||
error: null,
|
||||
stderrLogs: "",
|
||||
exitCode: null,
|
||||
partialData: null,
|
||||
}
|
||||
|
||||
process.stderr.on("data", (data) => {
|
||||
processState.stderrLogs += data.toString()
|
||||
})
|
||||
|
||||
process.on("close", (code) => {
|
||||
processState.exitCode = code
|
||||
})
|
||||
|
||||
process.on("error", (err) => {
|
||||
processState.error = err
|
||||
})
|
||||
|
||||
for await (const line of rl) {
|
||||
if (processState.error) {
|
||||
throw processState.error
|
||||
}
|
||||
|
||||
if (line.trim()) {
|
||||
const chunk = parseChunk(line, processState)
|
||||
|
||||
if (!chunk) {
|
||||
continue
|
||||
}
|
||||
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
// We rely on the assistant message. If the output was truncated, it's better having a poorly formatted message
|
||||
// from which to extract something, than throwing an error/showing the model didn't return any messages.
|
||||
if (processState.partialData && processState.partialData.startsWith(`{"type":"assistant"`)) {
|
||||
yield processState.partialData
|
||||
}
|
||||
|
||||
const { exitCode } = await process
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
const errorOutput = processState.error?.message || processState.stderrLogs?.trim()
|
||||
throw new Error(
|
||||
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput}` : ""}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rl.close()
|
||||
if (!process.killed) {
|
||||
process.kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We want the model to use our custom tool format instead of built-in tools.
|
||||
// Disabling built-in tools prevents tool-only responses and ensures text output.
|
||||
const claudeCodeTools = [
|
||||
"Task",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"exit_plan_mode",
|
||||
"Read",
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Write",
|
||||
"NotebookRead",
|
||||
"NotebookEdit",
|
||||
"WebFetch",
|
||||
"TodoRead",
|
||||
"TodoWrite",
|
||||
"WebSearch",
|
||||
].join(",")
|
||||
|
||||
const CLAUDE_CODE_TIMEOUT = 900000 // 15 minutes
|
||||
|
||||
function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions) {
|
||||
const claudePath = path || "claude"
|
||||
|
||||
// TODO: Is it worh using sessions? Where do we store the session ID?
|
||||
const args = [
|
||||
"-p",
|
||||
JSON.stringify(messages),
|
||||
@@ -26,6 +118,8 @@ export function runClaudeCode({
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--disallowedTools",
|
||||
claudeCodeTools,
|
||||
// Cline will handle recursive calls
|
||||
"--max-turns",
|
||||
"1",
|
||||
@@ -39,7 +133,45 @@ export function runClaudeCode({
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: process.env,
|
||||
env: {
|
||||
...process.env,
|
||||
// The default is 32000. However, I've gotten larger responses, so we increase it unless the user specified it.
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || "64000",
|
||||
},
|
||||
cwd,
|
||||
maxBuffer: 1024 * 1024 * 1000,
|
||||
timeout: CLAUDE_CODE_TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
function parseChunk(data: string, processState: ProcessState) {
|
||||
if (processState.partialData) {
|
||||
processState.partialData += data
|
||||
|
||||
const chunk = attemptParseChunk(processState.partialData)
|
||||
|
||||
if (!chunk) {
|
||||
return null
|
||||
}
|
||||
|
||||
processState.partialData = null
|
||||
return chunk
|
||||
}
|
||||
|
||||
const chunk = attemptParseChunk(data)
|
||||
|
||||
if (!chunk) {
|
||||
processState.partialData = data
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
function attemptParseChunk(data: string): ClaudeCodeMessage | null {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (error) {
|
||||
console.error("Error parsing chunk:", error, data.length)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
type InitMessage = {
|
||||
type: "system"
|
||||
subtype: "init"
|
||||
session_id: string
|
||||
tools: string[]
|
||||
mcp_servers: string[]
|
||||
}
|
||||
|
||||
type ClaudeCodeContent = {
|
||||
type: "text"
|
||||
text: string
|
||||
apiKeySource: "none" | "/login managed key" | string
|
||||
}
|
||||
|
||||
type AssistantMessage = {
|
||||
type: "assistant"
|
||||
message: {
|
||||
id: string
|
||||
type: "message"
|
||||
role: "assistant"
|
||||
model: string
|
||||
content: ClaudeCodeContent[]
|
||||
stop_reason: null
|
||||
stop_sequence: null
|
||||
usage: {
|
||||
input_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
output_tokens: number
|
||||
service_tier: "standard"
|
||||
}
|
||||
}
|
||||
message: Anthropic.Messages.Message
|
||||
session_id: string
|
||||
}
|
||||
|
||||
@@ -39,13 +22,12 @@ type ErrorMessage = {
|
||||
type ResultMessage = {
|
||||
type: "result"
|
||||
subtype: "success"
|
||||
cost_usd: number
|
||||
total_cost_usd: number
|
||||
is_error: boolean
|
||||
duration_ms: number
|
||||
duration_api_ms: number
|
||||
num_turns: number
|
||||
result: string
|
||||
total_cost: number
|
||||
session_id: string
|
||||
}
|
||||
|
||||
|
||||
+25
-5
@@ -230,11 +230,31 @@ export const anthropicModels = {
|
||||
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
|
||||
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-20250514"
|
||||
export const claudeCodeModels = {
|
||||
"claude-sonnet-4-20250514": anthropicModels["claude-sonnet-4-20250514"],
|
||||
"claude-opus-4-20250514": anthropicModels["claude-opus-4-20250514"],
|
||||
"claude-3-7-sonnet-20250219": anthropicModels["claude-3-7-sonnet-20250219"],
|
||||
"claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
|
||||
"claude-3-5-haiku-20241022": anthropicModels["claude-3-5-haiku-20241022"],
|
||||
"claude-sonnet-4-20250514": {
|
||||
...anthropicModels["claude-sonnet-4-20250514"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-4-20250514": {
|
||||
...anthropicModels["claude-opus-4-20250514"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-3-7-sonnet-20250219": {
|
||||
...anthropicModels["claude-3-7-sonnet-20250219"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-3-5-sonnet-20241022": {
|
||||
...anthropicModels["claude-3-5-sonnet-20241022"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-3-5-haiku-20241022": {
|
||||
...anthropicModels["claude-3-5-haiku-20241022"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// AWS Bedrock
|
||||
|
||||
Reference in New Issue
Block a user