Compare commits

...

10 Commits

Author SHA1 Message Date
Cline Evaluation bdd2661382 Fix the model infos and stop supporting images 2025-06-23 15:23:34 -06:00
BarreiroT ad25d5ef44 Set the total cost to 0 instead of leaving it undefined 2025-06-23 07:09:28 +02:00
BarreiroT 18e0f8c079 Improve output handling. Prefer returning partial data to nothing. 2025-06-23 07:06:01 +02:00
BarreiroT 1210b40738 Do not log costs when using a subscription, improve error handling and refactor rl usage 2025-06-23 07:05:53 +02:00
BarreiroT 30b2d8d6fa Handle incomplete chunks 2025-06-23 06:42:20 +02:00
BarreiroT 40af4058d8 disallow tools 2025-06-23 06:42:20 +02:00
BarreiroT a3b37bb841 refactor 2025-06-23 06:42:20 +02:00
BarreiroT e2e18606f4 Add changeset 2025-06-23 06:42:20 +02:00
BarreiroT debfcce26a Parse chunks separately 2025-06-23 06:42:20 +02:00
BarreiroT 174260c77a Handle partial messages 2025-06-23 06:42:20 +02:00
6 changed files with 272 additions and 115 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: Improve claude code error handling
+62 -77
View File
@@ -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,
}
})
}
+142 -10
View File
@@ -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
}
}
+5 -23
View File
@@ -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
View File
@@ -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