feat: add Claude Code provider for local CLI integration

- Add new provider that executes local claude CLI tool
- Support streaming responses from CLI JSON output
- Add configuration UI for setting CLI path
- Include all necessary type definitions and models
- Add English translations for new provider

This allows users to use Claude models through a locally installed
command-line tool instead of API endpoints.
This commit is contained in:
hannesrudolph 2025-06-18 20:20:13 -06:00
parent 72cb248ef8
commit 0393752e2d
17 changed files with 432 additions and 5 deletions

View file

@ -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<ProviderSettings, "apiProvider">
import type { ClineMessage, TokenUsage } from "./message.js"
import type { ToolUsage, ToolName } from "./tool.js"
import type { IpcMessage, IpcServerEvents, IsSubtask } from "./ipc.js"

View file

@ -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,

View file

@ -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<string, ModelInfo>

View file

@ -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"

View file

@ -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":

View file

@ -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
}
}
}

View file

@ -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"

64
src/api/retry.ts Normal file
View file

@ -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<T extends (...args: any[]) => ApiStream>(
options: {
maxRetries?: number
baseDelay?: number
maxDelay?: number
} = {},
) {
const { maxRetries = MAX_RETRIES, baseDelay = INITIAL_DELAY_MS } = options
return function (
_target: T,
_context: ClassMethodDecoratorContext<unknown, T>,
): (this: unknown, ...args: Parameters<T>) => ApiStream {
const originalMethod = _target
return async function* (this: unknown, ...args: Parameters<T>): 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
}
}
}

View file

@ -1,6 +1,12 @@
export type ApiStream = AsyncGenerator<ApiStreamChunk>
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"

View file

@ -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"
}
}
}
}

View file

@ -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,
})
}

View file

@ -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

View file

@ -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<ProviderSettings, "apiProvider">
// Re-export ApiHandlerOptions for backward compatibility
export type { ApiHandlerOptions }
// RouterName

View file

@ -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 = ({
<Anthropic apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProvider === "claude-code" && (
<ClaudeCode apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProvider === "openai-native" && (
<OpenAI apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}

View file

@ -3,6 +3,7 @@ import {
type ModelInfo,
anthropicModels,
bedrockModels,
claudeCodeModels,
deepSeekModels,
geminiModels,
mistralModels,
@ -15,6 +16,7 @@ import {
export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, ModelInfo>>> = {
anthropic: anthropicModels,
"claude-code": claudeCodeModels,
bedrock: bedrockModels,
deepseek: deepSeekModels,
gemini: geminiModels,
@ -29,6 +31,7 @@ export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, Mod
export const PROVIDERS = [
{ value: "openrouter", label: "OpenRouter" },
{ value: "anthropic", label: "Anthropic" },
{ value: "claude-code", label: "Claude Code" },
{ value: "gemini", label: "Google Gemini" },
{ value: "deepseek", label: "DeepSeek" },
{ value: "openai-native", label: "OpenAI" },

View file

@ -0,0 +1,40 @@
import React from "react"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { type ProviderSettings } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
interface ClaudeCodeProps {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
}
export const ClaudeCode: React.FC<ClaudeCodeProps> = ({ apiConfiguration, setApiConfigurationField }) => {
const { t } = useAppTranslation()
const handleInputChange = (e: Event | React.FormEvent<HTMLElement>) => {
const element = e.target as HTMLInputElement
setApiConfigurationField("claudeCodePath", element.value)
}
return (
<div>
<VSCodeTextField
value={apiConfiguration?.claudeCodePath || ""}
style={{ width: "100%", marginTop: 3 }}
type="text"
onInput={handleInputChange}
placeholder={t("settings:providers.claudeCode.placeholder")}>
{t("settings:providers.claudeCode.pathLabel")}
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
{t("settings:providers.claudeCode.description")}
</p>
</div>
)
}

View file

@ -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"