Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { codebaseIndexProviderSchema } from "./codebase-index.js"

export const providerNames = [
"anthropic",
"claude-code",
"glama",
"openrouter",
"bedrock",
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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") })),
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/types/src/providers/claude-code.ts
Original file line number Diff line number Diff line change
@@ -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>
1 change: 1 addition & 0 deletions packages/types/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
3 changes: 3 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
GroqHandler,
ChutesHandler,
LiteLLMHandler,
ClaudeCodeHandler,
} from "./providers"

export interface SingleCompletionHandler {
Expand Down Expand Up @@ -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":
Expand Down
171 changes: 171 additions & 0 deletions src/api/providers/claude-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { 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"
import { t } from "../../i18n"
import { ApiHandlerOptions } from "../../shared/api"

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) {
if (errorOutput) {
throw new Error(
t("common:errors.claudeCode.processExitedWithError", {
exitCode,
output: errorOutput.trim(),
}),
)
}
throw new Error(t("common:errors.claudeCode.processExited", { exitCode }))
}

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 ||
t("common:errors.claudeCode.stoppedWithReason", { reason: message.stop_reason })

if (errorMessage.includes("Invalid model name")) {
throw new Error(errorMessage + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`)
}

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) {
// Only use the cost from the CLI if provided
// Don't calculate cost as it may be $0 for subscription users
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],
}
}

// TODO: 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
}
}
}
1 change: 1 addition & 0 deletions src/api/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion src/api/transform/stream.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
16 changes: 14 additions & 2 deletions src/i18n/locales/ca/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,14 @@
"share_no_active_task": "No hi ha cap tasca activa per compartir",
"share_auth_required": "Es requereix autenticació. Si us plau, inicia sessió per compartir tasques.",
"share_not_enabled": "La compartició de tasques no està habilitada per a aquesta organització.",
"share_task_not_found": "Tasca no trobada o accés denegat."
"share_task_not_found": "Tasca no trobada o accés denegat.",
"claudeCode": {
"processExited": "El procés Claude Code ha sortit amb codi {{exitCode}}.",
"errorOutput": "Sortida d'error: {{output}}",
"processExitedWithError": "El procés Claude Code ha sortit amb codi {{exitCode}}. Sortida d'error: {{output}}",
"stoppedWithReason": "Claude Code s'ha aturat per la raó: {{reason}}",
"apiKeyModelPlanMismatch": "Les claus API i els plans de subscripció permeten models diferents. Assegura't que el model seleccionat estigui inclòs al teu pla."
}
},
"warnings": {
"no_terminal_content": "No s'ha seleccionat contingut de terminal",
Expand Down Expand Up @@ -105,7 +112,12 @@
"settings": {
"providers": {
"groqApiKey": "Clau API de Groq",
"getGroqApiKey": "Obté la clau API de Groq"
"getGroqApiKey": "Obté la clau API de Groq",
"claudeCode": {
"pathLabel": "Ruta de Claude Code",
"description": "Ruta opcional a la teva CLI de Claude Code. Per defecte 'claude' si no s'estableix.",
"placeholder": "Per defecte: claude"
}
}
},
"mdm": {
Expand Down
16 changes: 14 additions & 2 deletions src/i18n/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,14 @@
"share_no_active_task": "Keine aktive Aufgabe zum Teilen",
"share_auth_required": "Authentifizierung erforderlich. Bitte melde dich an, um Aufgaben zu teilen.",
"share_not_enabled": "Aufgabenfreigabe ist für diese Organisation nicht aktiviert.",
"share_task_not_found": "Aufgabe nicht gefunden oder Zugriff verweigert."
"share_task_not_found": "Aufgabe nicht gefunden oder Zugriff verweigert.",
"claudeCode": {
"processExited": "Claude Code Prozess wurde mit Code {{exitCode}} beendet.",
"errorOutput": "Fehlerausgabe: {{output}}",
"processExitedWithError": "Claude Code Prozess wurde mit Code {{exitCode}} beendet. Fehlerausgabe: {{output}}",
"stoppedWithReason": "Claude Code wurde mit Grund gestoppt: {{reason}}",
"apiKeyModelPlanMismatch": "API-Schlüssel und Abonnement-Pläne erlauben verschiedene Modelle. Stelle sicher, dass das ausgewählte Modell in deinem Plan enthalten ist."
}
},
"warnings": {
"no_terminal_content": "Kein Terminal-Inhalt ausgewählt",
Expand Down Expand Up @@ -105,7 +112,12 @@
"settings": {
"providers": {
"groqApiKey": "Groq API-Schlüssel",
"getGroqApiKey": "Groq API-Schlüssel erhalten"
"getGroqApiKey": "Groq API-Schlüssel erhalten",
"claudeCode": {
"pathLabel": "Claude Code Pfad",
"description": "Optionaler Pfad zu deiner Claude Code CLI. Standardmäßig 'claude', falls nicht festgelegt.",
"placeholder": "Standard: claude"
}
}
},
"mdm": {
Expand Down
9 changes: 8 additions & 1 deletion src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,14 @@
"share_no_active_task": "No active task to share",
"share_auth_required": "Authentication required. Please sign in to share tasks.",
"share_not_enabled": "Task sharing is not enabled for this organization.",
"share_task_not_found": "Task not found or access denied."
"share_task_not_found": "Task not found or access denied.",
"claudeCode": {
"processExited": "Claude Code process exited with code {{exitCode}}.",
"errorOutput": "Error output: {{output}}",
"processExitedWithError": "Claude Code process exited with code {{exitCode}}. Error output: {{output}}",
"stoppedWithReason": "Claude Code stopped with reason: {{reason}}",
"apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan."
}
},
"warnings": {
"no_terminal_content": "No terminal content selected",
Expand Down
16 changes: 14 additions & 2 deletions src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,14 @@
"share_no_active_task": "No hay tarea activa para compartir",
"share_auth_required": "Se requiere autenticación. Por favor, inicia sesión para compartir tareas.",
"share_not_enabled": "La compartición de tareas no está habilitada para esta organización.",
"share_task_not_found": "Tarea no encontrada o acceso denegado."
"share_task_not_found": "Tarea no encontrada o acceso denegado.",
"claudeCode": {
"processExited": "El proceso de Claude Code terminó con código {{exitCode}}.",
"errorOutput": "Salida de error: {{output}}",
"processExitedWithError": "El proceso de Claude Code terminó con código {{exitCode}}. Salida de error: {{output}}",
"stoppedWithReason": "Claude Code se detuvo por la razón: {{reason}}",
"apiKeyModelPlanMismatch": "Las claves API y los planes de suscripción permiten diferentes modelos. Asegúrate de que el modelo seleccionado esté incluido en tu plan."
}
},
"warnings": {
"no_terminal_content": "No hay contenido de terminal seleccionado",
Expand Down Expand Up @@ -105,7 +112,12 @@
"settings": {
"providers": {
"groqApiKey": "Clave API de Groq",
"getGroqApiKey": "Obtener clave API de Groq"
"getGroqApiKey": "Obtener clave API de Groq",
"claudeCode": {
"pathLabel": "Ruta de Claude Code",
"description": "Ruta opcional a tu CLI de Claude Code. Por defecto 'claude' si no se establece.",
"placeholder": "Por defecto: claude"
}
}
},
"mdm": {
Expand Down
Loading