Skip to content

Commit ff9b6b3

Browse files
hannesrudolphCopilotdaniel-lxsmrubens
authored
feat: add Claude Code provider for local CLI integration (#4864)
Co-authored-by: Copilot <[email protected]> Co-authored-by: Daniel <[email protected]> Co-authored-by: Daniel Riccio <[email protected]> Co-authored-by: Matt Rubens <[email protected]>
1 parent 9750d86 commit ff9b6b3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+714
-52
lines changed

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

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/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/ca/common.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,14 @@
6666
"share_no_active_task": "No hi ha cap tasca activa per compartir",
6767
"share_auth_required": "Es requereix autenticació. Si us plau, inicia sessió per compartir tasques.",
6868
"share_not_enabled": "La compartició de tasques no està habilitada per a aquesta organització.",
69-
"share_task_not_found": "Tasca no trobada o accés denegat."
69+
"share_task_not_found": "Tasca no trobada o accés denegat.",
70+
"claudeCode": {
71+
"processExited": "El procés Claude Code ha sortit amb codi {{exitCode}}.",
72+
"errorOutput": "Sortida d'error: {{output}}",
73+
"processExitedWithError": "El procés Claude Code ha sortit amb codi {{exitCode}}. Sortida d'error: {{output}}",
74+
"stoppedWithReason": "Claude Code s'ha aturat per la raó: {{reason}}",
75+
"apiKeyModelPlanMismatch": "Les claus API i els plans de subscripció permeten models diferents. Assegura't que el model seleccionat estigui inclòs al teu pla."
76+
}
7077
},
7178
"warnings": {
7279
"no_terminal_content": "No s'ha seleccionat contingut de terminal",
@@ -105,7 +112,12 @@
105112
"settings": {
106113
"providers": {
107114
"groqApiKey": "Clau API de Groq",
108-
"getGroqApiKey": "Obté la clau API de Groq"
115+
"getGroqApiKey": "Obté la clau API de Groq",
116+
"claudeCode": {
117+
"pathLabel": "Ruta de Claude Code",
118+
"description": "Ruta opcional a la teva CLI de Claude Code. Per defecte 'claude' si no s'estableix.",
119+
"placeholder": "Per defecte: claude"
120+
}
109121
}
110122
},
111123
"mdm": {

src/i18n/locales/de/common.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@
6262
"share_no_active_task": "Keine aktive Aufgabe zum Teilen",
6363
"share_auth_required": "Authentifizierung erforderlich. Bitte melde dich an, um Aufgaben zu teilen.",
6464
"share_not_enabled": "Aufgabenfreigabe ist für diese Organisation nicht aktiviert.",
65-
"share_task_not_found": "Aufgabe nicht gefunden oder Zugriff verweigert."
65+
"share_task_not_found": "Aufgabe nicht gefunden oder Zugriff verweigert.",
66+
"claudeCode": {
67+
"processExited": "Claude Code Prozess wurde mit Code {{exitCode}} beendet.",
68+
"errorOutput": "Fehlerausgabe: {{output}}",
69+
"processExitedWithError": "Claude Code Prozess wurde mit Code {{exitCode}} beendet. Fehlerausgabe: {{output}}",
70+
"stoppedWithReason": "Claude Code wurde mit Grund gestoppt: {{reason}}",
71+
"apiKeyModelPlanMismatch": "API-Schlüssel und Abonnement-Pläne erlauben verschiedene Modelle. Stelle sicher, dass das ausgewählte Modell in deinem Plan enthalten ist."
72+
}
6673
},
6774
"warnings": {
6875
"no_terminal_content": "Kein Terminal-Inhalt ausgewählt",
@@ -105,7 +112,12 @@
105112
"settings": {
106113
"providers": {
107114
"groqApiKey": "Groq API-Schlüssel",
108-
"getGroqApiKey": "Groq API-Schlüssel erhalten"
115+
"getGroqApiKey": "Groq API-Schlüssel erhalten",
116+
"claudeCode": {
117+
"pathLabel": "Claude Code Pfad",
118+
"description": "Optionaler Pfad zu deiner Claude Code CLI. Standardmäßig 'claude', falls nicht festgelegt.",
119+
"placeholder": "Standard: claude"
120+
}
109121
}
110122
},
111123
"mdm": {

src/i18n/locales/en/common.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@
6262
"share_no_active_task": "No active task to share",
6363
"share_auth_required": "Authentication required. Please sign in to share tasks.",
6464
"share_not_enabled": "Task sharing is not enabled for this organization.",
65-
"share_task_not_found": "Task not found or access denied."
65+
"share_task_not_found": "Task not found or access denied.",
66+
"claudeCode": {
67+
"processExited": "Claude Code process exited with code {{exitCode}}.",
68+
"errorOutput": "Error output: {{output}}",
69+
"processExitedWithError": "Claude Code process exited with code {{exitCode}}. Error output: {{output}}",
70+
"stoppedWithReason": "Claude Code stopped with reason: {{reason}}",
71+
"apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan."
72+
}
6673
},
6774
"warnings": {
6875
"no_terminal_content": "No terminal content selected",

0 commit comments

Comments
 (0)