diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 6fb181b573..9d86bcd333 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -3,6 +3,10 @@ import type { Socket } from "net" import type { RooCodeSettings } from "./global-settings.js" import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js" + +// ApiHandlerOptions + +export type ApiHandlerOptions = Omit import type { ClineMessage, TokenUsage } from "./message.js" import type { ToolUsage, ToolName } from "./tool.js" import type { IpcMessage, IpcServerEvents, IsSubtask } from "./ipc.js" diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 65e3f9b5b6..609d0fefbf 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -9,6 +9,7 @@ import { codebaseIndexProviderSchema } from "./codebase-index.js" export const providerNames = [ "anthropic", + "claude-code", "glama", "openrouter", "bedrock", @@ -76,6 +77,10 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({ anthropicUseAuthToken: z.boolean().optional(), }) +const claudeCodeSchema = apiModelIdProviderModelSchema.extend({ + claudeCodePath: z.string().optional(), +}) + const glamaSchema = baseProviderSettingsSchema.extend({ glamaModelId: z.string().optional(), glamaApiKey: z.string().optional(), @@ -208,6 +213,7 @@ const defaultSchema = z.object({ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [ anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })), + claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })), glamaSchema.merge(z.object({ apiProvider: z.literal("glama") })), openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })), bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })), @@ -234,6 +240,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv export const providerSettingsSchema = z.object({ apiProvider: providerNamesSchema.optional(), ...anthropicSchema.shape, + ...claudeCodeSchema.shape, ...glamaSchema.shape, ...openRouterSchema.shape, ...bedrockSchema.shape, diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts new file mode 100644 index 0000000000..ebf053a532 --- /dev/null +++ b/packages/types/src/providers/claude-code.ts @@ -0,0 +1,13 @@ +import type { ModelInfo } from "../model.js" +import { anthropicModels } from "./anthropic.js" + +// Claude Code +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"], +} as const satisfies Record diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 5f1c08041f..4a2b7f1885 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -1,6 +1,7 @@ export * from "./anthropic.js" export * from "./bedrock.js" export * from "./chutes.js" +export * from "./claude-code.js" export * from "./deepseek.js" export * from "./gemini.js" export * from "./glama.js" diff --git a/src/api/index.ts b/src/api/index.ts index 8b09bf4cf9..960209f714 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -27,6 +27,7 @@ import { GroqHandler, ChutesHandler, LiteLLMHandler, + ClaudeCodeHandler, } from "./providers" export interface SingleCompletionHandler { @@ -64,6 +65,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { switch (apiProvider) { case "anthropic": return new AnthropicHandler(options) + case "claude-code": + return new ClaudeCodeHandler(options) case "glama": return new GlamaHandler(options) case "openrouter": diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts new file mode 100644 index 0000000000..6aef469d43 --- /dev/null +++ b/src/api/providers/claude-code.ts @@ -0,0 +1,168 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import { + type ApiHandlerOptions, + claudeCodeDefaultModelId, + type ClaudeCodeModelId, + claudeCodeModels, +} from "@roo-code/types" +import { type ApiHandler } from ".." +import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream" +import { runClaudeCode } from "../../integrations/claude-code/run" +import { ClaudeCodeMessage } from "../../integrations/claude-code/types" +import { BaseProvider } from "./base-provider" + +export class ClaudeCodeHandler extends BaseProvider implements ApiHandler { + private options: ApiHandlerOptions + + constructor(options: ApiHandlerOptions) { + super() + this.options = options + } + + override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + const claudeProcess = runClaudeCode({ + systemPrompt, + messages, + 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 = { + type: "usage", + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } + + while (exitCode !== 0 || dataQueue.length > 0) { + if (dataQueue.length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + + 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) { + yield { + type: "text", + text: data || "", + } + + continue + } + + if (chunk.type === "system" && chunk.subtype === "init") { + 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 (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.`, + ) + } + + 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) + } + } + + usage.inputTokens += message.usage.input_tokens + usage.outputTokens += message.usage.output_tokens + usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0) + usage.cacheWriteTokens = + (usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0) + + continue + } + + if (chunk.type === "result" && "result" in chunk) { + usage.totalCost = chunk.cost_usd || 0 + + yield usage + } + + if (processError) { + throw processError + } + } + } + + getModel() { + const modelId = this.options.apiModelId + if (modelId && modelId in claudeCodeModels) { + const id = modelId as ClaudeCodeModelId + return { id, info: claudeCodeModels[id] } + } + + return { + id: claudeCodeDefaultModelId, + 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 + } + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index b305118188..93df5c58ff 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -2,6 +2,7 @@ export { AnthropicVertexHandler } from "./anthropic-vertex" export { AnthropicHandler } from "./anthropic" export { AwsBedrockHandler } from "./bedrock" export { ChutesHandler } from "./chutes" +export { ClaudeCodeHandler } from "./claude-code" export { DeepSeekHandler } from "./deepseek" export { FakeAIHandler } from "./fake-ai" export { GeminiHandler } from "./gemini" diff --git a/src/api/retry.ts b/src/api/retry.ts new file mode 100644 index 0000000000..3a8849e4af --- /dev/null +++ b/src/api/retry.ts @@ -0,0 +1,64 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import { ApiStream, ApiStreamError } from "./transform/stream" +import delay from "delay" + +const RETRIABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504] +const MAX_RETRIES = 5 +const INITIAL_DELAY_MS = 2000 + +// `withRetry` is a decorator that adds retry logic to a method that returns an async generator. +// It will retry the method if it fails with a retriable error. +// It uses exponential backoff with jitter to delay between retries. +export function withRetry ApiStream>( + options: { + maxRetries?: number + baseDelay?: number + maxDelay?: number + } = {}, +) { + const { maxRetries = MAX_RETRIES, baseDelay = INITIAL_DELAY_MS } = options + + return function ( + _target: T, + _context: ClassMethodDecoratorContext, + ): (this: unknown, ...args: Parameters) => ApiStream { + const originalMethod = _target + + return async function* (this: unknown, ...args: Parameters): ApiStream { + let lastError: Error | undefined + for (let i = 0; i < maxRetries; i++) { + try { + yield* originalMethod.apply(this, args) + return + } catch (error: any) { + lastError = error + const isRetriable = + error instanceof Anthropic.APIError && + error.status && + RETRIABLE_STATUS_CODES.includes(error.status) + + if (!isRetriable) { + throw error + } + + const exponentialBackoff = Math.pow(2, i) + const jitter = Math.random() + const delayMs = Math.min( + options.maxDelay || Infinity, + baseDelay * exponentialBackoff * (1 + jitter), + ) + + await delay(delayMs) + } + } + + const error: ApiStreamError = { + type: "error", + error: "Retries exhausted", + message: lastError!.message, + } + + yield error + } + } +} diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index caa69a09fe..89655a3f56 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -1,6 +1,12 @@ export type ApiStream = AsyncGenerator -export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk +export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk | ApiStreamError + +export interface ApiStreamError { + type: "error" + error: string + message: string +} export interface ApiStreamTextChunk { type: "text" diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a505933b69..0c1dd0773a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -103,5 +103,14 @@ "organization_mismatch": "You must be authenticated with your organization's Roo Code Cloud account.", "verification_failed": "Unable to verify organization authentication." } + }, + "settings": { + "providers": { + "claudeCode": { + "pathLabel": "Claude Code Path", + "description": "Optional path to your Claude Code CLI. Defaults to 'claude' if not set.", + "placeholder": "Default: claude" + } + } } } diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts new file mode 100644 index 0000000000..7cae66a2eb --- /dev/null +++ b/src/integrations/claude-code/run.ts @@ -0,0 +1,44 @@ +import * as vscode from "vscode" +import Anthropic from "@anthropic-ai/sdk" +import { execa } from "execa" + +export function runClaudeCode({ + systemPrompt, + messages, + path, + modelId, +}: { + systemPrompt: string + messages: Anthropic.Messages.MessageParam[] + path?: string + modelId?: string +}) { + const claudePath = path || "claude" + + // TODO: Is it worh using sessions? Where do we store the session ID? + const args = [ + "-p", + JSON.stringify(messages), + "--system-prompt", + systemPrompt, + "--verbose", + "--output-format", + "stream-json", + // Cline will handle recursive calls + "--max-turns", + "1", + ] + + if (modelId) { + args.push("--model", modelId) + } + + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + return execa(claudePath, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + cwd, + }) +} diff --git a/src/integrations/claude-code/types.ts b/src/integrations/claude-code/types.ts new file mode 100644 index 0000000000..8b0c977808 --- /dev/null +++ b/src/integrations/claude-code/types.ts @@ -0,0 +1,52 @@ +type InitMessage = { + type: "system" + subtype: "init" + session_id: string + tools: string[] + mcp_servers: string[] +} + +type ClaudeCodeContent = { + type: "text" + text: 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" + } + } + session_id: string +} + +type ErrorMessage = { + type: "error" +} + +type ResultMessage = { + type: "result" + subtype: "success" + cost_usd: number + is_error: boolean + duration_ms: number + duration_api_ms: number + num_turns: number + result: string + total_cost: number + session_id: string +} + +export type ClaudeCodeMessage = InitMessage | AssistantMessage | ErrorMessage | ResultMessage diff --git a/src/shared/api.ts b/src/shared/api.ts index 8ad8828658..aa96d11cc5 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,8 +1,12 @@ -import { type ModelInfo, type ProviderSettings, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" +import { + type ModelInfo, + type ProviderSettings, + ANTHROPIC_DEFAULT_MAX_TOKENS, + type ApiHandlerOptions, +} from "@roo-code/types" -// ApiHandlerOptions - -export type ApiHandlerOptions = Omit +// Re-export ApiHandlerOptions for backward compatibility +export type { ApiHandlerOptions } // RouterName diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index c55999efbd..aff2be9b26 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -13,6 +13,7 @@ import { litellmDefaultModelId, openAiNativeDefaultModelId, anthropicDefaultModelId, + claudeCodeDefaultModelId, geminiDefaultModelId, deepSeekDefaultModelId, mistralDefaultModelId, @@ -36,6 +37,7 @@ import { Anthropic, Bedrock, Chutes, + ClaudeCode, DeepSeek, Gemini, Glama, @@ -254,6 +256,7 @@ const ApiOptions = ({ requesty: { field: "requestyModelId", default: requestyDefaultModelId }, litellm: { field: "litellmModelId", default: litellmDefaultModelId }, anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, + "claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, gemini: { field: "apiModelId", default: geminiDefaultModelId }, deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, @@ -383,6 +386,10 @@ const ApiOptions = ({ )} + {selectedProvider === "claude-code" && ( + + )} + {selectedProvider === "openai-native" && ( )} diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 5b808643e5..bbee8f990d 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -3,6 +3,7 @@ import { type ModelInfo, anthropicModels, bedrockModels, + claudeCodeModels, deepSeekModels, geminiModels, mistralModels, @@ -15,6 +16,7 @@ import { export const MODELS_BY_PROVIDER: Partial>> = { anthropic: anthropicModels, + "claude-code": claudeCodeModels, bedrock: bedrockModels, deepseek: deepSeekModels, gemini: geminiModels, @@ -29,6 +31,7 @@ export const MODELS_BY_PROVIDER: Partial void +} + +export const ClaudeCode: React.FC = ({ apiConfiguration, setApiConfigurationField }) => { + const { t } = useAppTranslation() + + const handleInputChange = (e: Event | React.FormEvent) => { + const element = e.target as HTMLInputElement + setApiConfigurationField("claudeCodePath", element.value) + } + + return ( +
+ + {t("settings:providers.claudeCode.pathLabel")} + + +

+ {t("settings:providers.claudeCode.description")} +

+
+ ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index b244fb515c..b195607430 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -1,6 +1,7 @@ export { Anthropic } from "./Anthropic" export { Bedrock } from "./Bedrock" export { Chutes } from "./Chutes" +export { ClaudeCode } from "./ClaudeCode" export { DeepSeek } from "./DeepSeek" export { Gemini } from "./Gemini" export { Glama } from "./Glama"