Skip to content

Commit 0393752

Browse files
committed
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.
1 parent 72cb248 commit 0393752

File tree

17 files changed

+433
-6
lines changed

17 files changed

+433
-6
lines changed

packages/types/src/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import type { Socket } from "net"
33

44
import type { RooCodeSettings } from "./global-settings.js"
55
import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js"
6+
7+
// ApiHandlerOptions
8+
9+
export type ApiHandlerOptions = Omit<ProviderSettings, "apiProvider">
610
import type { ClineMessage, TokenUsage } from "./message.js"
711
import type { ToolUsage, ToolName } from "./tool.js"
812
import type { IpcMessage, IpcServerEvents, IsSubtask } from "./ipc.js"

packages/types/src/provider-settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { codebaseIndexProviderSchema } from "./codebase-index.js"
99

1010
export const providerNames = [
1111
"anthropic",
12+
"claude-code",
1213
"glama",
1314
"openrouter",
1415
"bedrock",
@@ -76,6 +77,10 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({
7677
anthropicUseAuthToken: z.boolean().optional(),
7778
})
7879

80+
const claudeCodeSchema = apiModelIdProviderModelSchema.extend({
81+
claudeCodePath: z.string().optional(),
82+
})
83+
7984
const glamaSchema = baseProviderSettingsSchema.extend({
8085
glamaModelId: z.string().optional(),
8186
glamaApiKey: z.string().optional(),
@@ -208,6 +213,7 @@ const defaultSchema = z.object({
208213

209214
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
210215
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
216+
claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })),
211217
glamaSchema.merge(z.object({ apiProvider: z.literal("glama") })),
212218
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
213219
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
@@ -234,6 +240,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
234240
export const providerSettingsSchema = z.object({
235241
apiProvider: providerNamesSchema.optional(),
236242
...anthropicSchema.shape,
243+
...claudeCodeSchema.shape,
237244
...glamaSchema.shape,
238245
...openRouterSchema.shape,
239246
...bedrockSchema.shape,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { ModelInfo } from "../model.js"
2+
import { anthropicModels } from "./anthropic.js"
3+
4+
// Claude Code
5+
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
6+
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-20250514"
7+
export const claudeCodeModels = {
8+
"claude-sonnet-4-20250514": anthropicModels["claude-sonnet-4-20250514"],
9+
"claude-opus-4-20250514": anthropicModels["claude-opus-4-20250514"],
10+
"claude-3-7-sonnet-20250219": anthropicModels["claude-3-7-sonnet-20250219"],
11+
"claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
12+
"claude-3-5-haiku-20241022": anthropicModels["claude-3-5-haiku-20241022"],
13+
} as const satisfies Record<string, ModelInfo>

packages/types/src/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./anthropic.js"
22
export * from "./bedrock.js"
33
export * from "./chutes.js"
4+
export * from "./claude-code.js"
45
export * from "./deepseek.js"
56
export * from "./gemini.js"
67
export * from "./glama.js"

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
GroqHandler,
2828
ChutesHandler,
2929
LiteLLMHandler,
30+
ClaudeCodeHandler,
3031
} from "./providers"
3132

3233
export interface SingleCompletionHandler {
@@ -64,6 +65,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
6465
switch (apiProvider) {
6566
case "anthropic":
6667
return new AnthropicHandler(options)
68+
case "claude-code":
69+
return new ClaudeCodeHandler(options)
6770
case "glama":
6871
return new GlamaHandler(options)
6972
case "openrouter":

src/api/providers/claude-code.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { Anthropic } from "@anthropic-ai/sdk"
2+
import {
3+
type ApiHandlerOptions,
4+
claudeCodeDefaultModelId,
5+
type ClaudeCodeModelId,
6+
claudeCodeModels,
7+
} from "@roo-code/types"
8+
import { type ApiHandler } from ".."
9+
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
10+
import { runClaudeCode } from "../../integrations/claude-code/run"
11+
import { ClaudeCodeMessage } from "../../integrations/claude-code/types"
12+
import { BaseProvider } from "./base-provider"
13+
14+
export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
15+
private options: ApiHandlerOptions
16+
17+
constructor(options: ApiHandlerOptions) {
18+
super()
19+
this.options = options
20+
}
21+
22+
override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
23+
const claudeProcess = runClaudeCode({
24+
systemPrompt,
25+
messages,
26+
path: this.options.claudeCodePath,
27+
modelId: this.getModel().id,
28+
})
29+
30+
const dataQueue: string[] = []
31+
let processError = null
32+
let errorOutput = ""
33+
let exitCode: number | null = null
34+
35+
claudeProcess.stdout.on("data", (data) => {
36+
const output = data.toString()
37+
const lines = output.split("\n").filter((line: string) => line.trim() !== "")
38+
39+
for (const line of lines) {
40+
dataQueue.push(line)
41+
}
42+
})
43+
44+
claudeProcess.stderr.on("data", (data) => {
45+
errorOutput += data.toString()
46+
})
47+
48+
claudeProcess.on("close", (code) => {
49+
exitCode = code
50+
})
51+
52+
claudeProcess.on("error", (error) => {
53+
processError = error
54+
})
55+
56+
// Usage is included with assistant messages,
57+
// but cost is included in the result chunk
58+
let usage: ApiStreamUsageChunk = {
59+
type: "usage",
60+
inputTokens: 0,
61+
outputTokens: 0,
62+
cacheReadTokens: 0,
63+
cacheWriteTokens: 0,
64+
}
65+
66+
while (exitCode !== 0 || dataQueue.length > 0) {
67+
if (dataQueue.length === 0) {
68+
await new Promise((resolve) => setImmediate(resolve))
69+
}
70+
71+
if (exitCode !== null && exitCode !== 0) {
72+
throw new Error(
73+
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput.trim()}` : ""}`,
74+
)
75+
}
76+
77+
const data = dataQueue.shift()
78+
if (!data) {
79+
continue
80+
}
81+
82+
const chunk = this.attemptParseChunk(data)
83+
84+
if (!chunk) {
85+
yield {
86+
type: "text",
87+
text: data || "",
88+
}
89+
90+
continue
91+
}
92+
93+
if (chunk.type === "system" && chunk.subtype === "init") {
94+
continue
95+
}
96+
97+
if (chunk.type === "assistant" && "message" in chunk) {
98+
const message = chunk.message
99+
100+
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
101+
const errorMessage =
102+
message.content[0]?.text || `Claude Code stopped with reason: ${message.stop_reason}`
103+
104+
if (errorMessage.includes("Invalid model name")) {
105+
throw new Error(
106+
errorMessage +
107+
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
108+
)
109+
}
110+
111+
throw new Error(errorMessage)
112+
}
113+
114+
for (const content of message.content) {
115+
if (content.type === "text") {
116+
yield {
117+
type: "text",
118+
text: content.text,
119+
}
120+
} else {
121+
console.warn("Unsupported content type:", content.type)
122+
}
123+
}
124+
125+
usage.inputTokens += message.usage.input_tokens
126+
usage.outputTokens += message.usage.output_tokens
127+
usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0)
128+
usage.cacheWriteTokens =
129+
(usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0)
130+
131+
continue
132+
}
133+
134+
if (chunk.type === "result" && "result" in chunk) {
135+
usage.totalCost = chunk.cost_usd || 0
136+
137+
yield usage
138+
}
139+
140+
if (processError) {
141+
throw processError
142+
}
143+
}
144+
}
145+
146+
getModel() {
147+
const modelId = this.options.apiModelId
148+
if (modelId && modelId in claudeCodeModels) {
149+
const id = modelId as ClaudeCodeModelId
150+
return { id, info: claudeCodeModels[id] }
151+
}
152+
153+
return {
154+
id: claudeCodeDefaultModelId,
155+
info: claudeCodeModels[claudeCodeDefaultModelId],
156+
}
157+
}
158+
159+
// TOOD: Validate instead of parsing
160+
private attemptParseChunk(data: string): ClaudeCodeMessage | null {
161+
try {
162+
return JSON.parse(data)
163+
} catch (error) {
164+
console.error("Error parsing chunk:", error)
165+
return null
166+
}
167+
}
168+
}

src/api/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { AnthropicVertexHandler } from "./anthropic-vertex"
22
export { AnthropicHandler } from "./anthropic"
33
export { AwsBedrockHandler } from "./bedrock"
44
export { ChutesHandler } from "./chutes"
5+
export { ClaudeCodeHandler } from "./claude-code"
56
export { DeepSeekHandler } from "./deepseek"
67
export { FakeAIHandler } from "./fake-ai"
78
export { GeminiHandler } from "./gemini"

src/api/retry.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Anthropic } from "@anthropic-ai/sdk"
2+
import { ApiStream, ApiStreamError } from "./transform/stream"
3+
import delay from "delay"
4+
5+
const RETRIABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]
6+
const MAX_RETRIES = 5
7+
const INITIAL_DELAY_MS = 2000
8+
9+
// `withRetry` is a decorator that adds retry logic to a method that returns an async generator.
10+
// It will retry the method if it fails with a retriable error.
11+
// It uses exponential backoff with jitter to delay between retries.
12+
export function withRetry<T extends (...args: any[]) => ApiStream>(
13+
options: {
14+
maxRetries?: number
15+
baseDelay?: number
16+
maxDelay?: number
17+
} = {},
18+
) {
19+
const { maxRetries = MAX_RETRIES, baseDelay = INITIAL_DELAY_MS } = options
20+
21+
return function (
22+
_target: T,
23+
_context: ClassMethodDecoratorContext<unknown, T>,
24+
): (this: unknown, ...args: Parameters<T>) => ApiStream {
25+
const originalMethod = _target
26+
27+
return async function* (this: unknown, ...args: Parameters<T>): ApiStream {
28+
let lastError: Error | undefined
29+
for (let i = 0; i < maxRetries; i++) {
30+
try {
31+
yield* originalMethod.apply(this, args)
32+
return
33+
} catch (error: any) {
34+
lastError = error
35+
const isRetriable =
36+
error instanceof Anthropic.APIError &&
37+
error.status &&
38+
RETRIABLE_STATUS_CODES.includes(error.status)
39+
40+
if (!isRetriable) {
41+
throw error
42+
}
43+
44+
const exponentialBackoff = Math.pow(2, i)
45+
const jitter = Math.random()
46+
const delayMs = Math.min(
47+
options.maxDelay || Infinity,
48+
baseDelay * exponentialBackoff * (1 + jitter),
49+
)
50+
51+
await delay(delayMs)
52+
}
53+
}
54+
55+
const error: ApiStreamError = {
56+
type: "error",
57+
error: "Retries exhausted",
58+
message: lastError!.message,
59+
}
60+
61+
yield error
62+
}
63+
}
64+
}

src/api/transform/stream.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
export type ApiStream = AsyncGenerator<ApiStreamChunk>
22

3-
export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk
3+
export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk | ApiStreamError
4+
5+
export interface ApiStreamError {
6+
type: "error"
7+
error: string
8+
message: string
9+
}
410

511
export interface ApiStreamTextChunk {
612
type: "text"

src/i18n/locales/en/common.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,14 @@
103103
"organization_mismatch": "You must be authenticated with your organization's Roo Code Cloud account.",
104104
"verification_failed": "Unable to verify organization authentication."
105105
}
106+
},
107+
"settings": {
108+
"providers": {
109+
"claudeCode": {
110+
"pathLabel": "Claude Code Path",
111+
"description": "Optional path to your Claude Code CLI. Defaults to 'claude' if not set.",
112+
"placeholder": "Default: claude"
113+
}
114+
}
106115
}
107116
}

0 commit comments

Comments
 (0)