diff --git a/src/api/providers/__tests__/claude-code-alternative-providers.spec.ts b/src/api/providers/__tests__/claude-code-alternative-providers.spec.ts new file mode 100644 index 0000000000..9e36b02084 --- /dev/null +++ b/src/api/providers/__tests__/claude-code-alternative-providers.spec.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from "vitest" +import { ClaudeCodeHandler } from "../claude-code" +import * as os from "os" +import * as path from "path" +import type { ApiHandlerOptions } from "../../../shared/api" + +// Mock the fs module - matching the actual import style in claude-code.ts +vi.mock("fs", () => ({ + promises: { + readFile: vi.fn(), + }, +})) + +// Mock os module +vi.mock("os", () => ({ + homedir: vi.fn(() => "/home/user"), + platform: vi.fn(() => "linux"), +})) + +// Mock process.cwd +const originalCwd = process.cwd +beforeEach(() => { + process.cwd = vi.fn(() => "/workspace") + vi.clearAllMocks() +}) + +afterEach(() => { + process.cwd = originalCwd + vi.restoreAllMocks() +}) + +describe("ClaudeCodeHandler - Alternative Providers", () => { + describe("readClaudeCodeConfig", () => { + it("should read config from ~/.claude/settings.json first", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { + env: { + ANTHROPIC_BASE_URL: "https://api.z.ai/v1", + ANTHROPIC_MODEL: "glm-4.5", + }, + } + + ;(fs.readFile as Mock).mockImplementation(async (filePath: string) => { + if (filePath === path.join("/home/user", ".claude", "settings.json")) { + return JSON.stringify(mockConfig) + } + throw new Error("File not found") + }) + + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + // Access private method through any type assertion for testing + const config = await (handler as any).readClaudeCodeConfig() + + expect(config).toEqual(mockConfig) + expect(fs.readFile).toHaveBeenCalledWith(path.join("/home/user", ".claude", "settings.json"), "utf8") + }) + + it("should try multiple config locations in order", async () => { + const { promises: fs } = await import("fs") + ;(fs.readFile as Mock).mockRejectedValue(new Error("File not found")) + + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + // Clear cached config to force fresh read + ;(handler as any).cachedConfig = null + const config = await (handler as any).readClaudeCodeConfig() + + expect(config).toBeNull() + // The constructor calls initializeModelDetection which also reads config, + // so we expect 10 calls total (5 from constructor + 5 from our test) + expect(fs.readFile).toHaveBeenCalledTimes(10) + }) + + it("should cache config after first read", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { env: { ANTHROPIC_BASE_URL: "https://api.z.ai/v1" } } + ;(fs.readFile as Mock).mockImplementation(async (filePath: string) => { + if (filePath === path.join("/home/user", ".claude", "settings.json")) { + return JSON.stringify(mockConfig) + } + throw new Error("File not found") + }) + + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + // Clear any cached config first + ;(handler as any).cachedConfig = null + const config1 = await (handler as any).readClaudeCodeConfig() + const config2 = await (handler as any).readClaudeCodeConfig() + + expect(config1).toBe(config2) // Same reference, cached + expect(config1).toEqual(mockConfig) + // Called once from constructor's initializeModelDetection and once from our test + expect(fs.readFile).toHaveBeenCalledTimes(2) + }) + }) + + describe("detectProviderFromConfig", () => { + it("should detect Z.ai provider", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { + env: { + ANTHROPIC_BASE_URL: "https://api.z.ai/v1", + }, + } + + ;(fs.readFile as Mock).mockImplementation(async () => JSON.stringify(mockConfig)) + + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + // Clear cached config to force fresh read + ;(handler as any).cachedConfig = null + const provider = await (handler as any).detectProviderFromConfig() + + expect(provider).toBeTruthy() + expect(provider?.provider).toBe("zai") + expect(provider?.models).toHaveProperty("glm-4.5") + expect(provider?.models).toHaveProperty("glm-4.5-air") + expect(provider?.models).toHaveProperty("glm-4.6") + }) + + it("should detect Qwen provider from Dashscope URL", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { + env: { + ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/api/v1", + }, + } + + ;(fs.readFile as Mock).mockImplementation(async () => JSON.stringify(mockConfig)) + + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + // Clear cached config to force fresh read + ;(handler as any).cachedConfig = null + const provider = await (handler as any).detectProviderFromConfig() + + expect(provider).toBeTruthy() + expect(provider?.provider).toBe("qwen-code") + expect(provider?.models).toHaveProperty("qwen3-coder-plus") + expect(provider?.models).toHaveProperty("qwen3-coder-flash") + }) + + it("should detect DeepSeek provider", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { + env: { + ANTHROPIC_BASE_URL: "https://api.deepseek.com/v1", + }, + } + + ;(fs.readFile as Mock).mockImplementation(async () => JSON.stringify(mockConfig)) + + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + // Clear cached config to force fresh read + ;(handler as any).cachedConfig = null + const provider = await (handler as any).detectProviderFromConfig() + + expect(provider).toBeTruthy() + expect(provider?.provider).toBe("deepseek") + expect(provider?.models).toHaveProperty("deepseek-chat") + expect(provider?.models).toHaveProperty("deepseek-reasoner") + }) + + it("should return null for standard Claude API", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { + env: { + ANTHROPIC_BASE_URL: "https://api.anthropic.com/v1", + }, + } + + ;(fs.readFile as Mock).mockImplementation(async () => JSON.stringify(mockConfig)) + + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + // Clear cached config to force fresh read + ;(handler as any).cachedConfig = null + const provider = await (handler as any).detectProviderFromConfig() + + expect(provider).toBeNull() + }) + + it("should return null when no config exists", async () => { + const { promises: fs } = await import("fs") + ;(fs.readFile as Mock).mockImplementation(async () => { + throw new Error("File not found") + }) + + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + // Clear cached config to force fresh read + ;(handler as any).cachedConfig = null + const provider = await (handler as any).detectProviderFromConfig() + + expect(provider).toBeNull() + }) + }) + + describe("getAvailableModels", () => { + it("should return Z.ai models when Z.ai is configured", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { + env: { + ANTHROPIC_BASE_URL: "https://api.z.ai/v1", + ANTHROPIC_MODEL: "glm-4.5", + }, + } + + ;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockConfig)) + + const result = await ClaudeCodeHandler.getAvailableModels() + + expect(result).toBeTruthy() + expect(result?.provider).toBe("zai") + expect(result?.models).toHaveProperty("glm-4.5") + expect(Object.keys(result?.models || {})).toContain("glm-4.5") + expect(Object.keys(result?.models || {})).toContain("glm-4.5-air") + expect(Object.keys(result?.models || {})).toContain("glm-4.6") + }) + + it("should return default Claude models when no alternative provider", async () => { + const { promises: fs } = await import("fs") + ;(fs.readFile as Mock).mockRejectedValue(new Error("File not found")) + + const result = await ClaudeCodeHandler.getAvailableModels() + + expect(result).toBeTruthy() + expect(result?.provider).toBe("claude-code") + expect(result?.models).toHaveProperty("claude-sonnet-4-5") + expect(result?.models).toHaveProperty("claude-opus-4-1-20250805") + }) + + it("should handle errors gracefully", async () => { + const { promises: fs } = await import("fs") + ;(fs.readFile as Mock).mockRejectedValue(new Error("Permission denied")) + + const result = await ClaudeCodeHandler.getAvailableModels() + + expect(result).toBeTruthy() + expect(result?.provider).toBe("claude-code") + expect(result?.models).toBeDefined() + }) + }) + + describe("getModel", () => { + it("should return cached model info for alternative provider", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { + env: { + ANTHROPIC_BASE_URL: "https://api.z.ai/v1", + ANTHROPIC_MODEL: "glm-4.5", + }, + } + + ;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockConfig)) + + const handler = new ClaudeCodeHandler({ apiModelId: "glm-4.5" } as ApiHandlerOptions) + + // Wait for initialization + await new Promise((resolve) => setTimeout(resolve, 100)) + + const model = handler.getModel() + + expect(model.id).toBe("glm-4.5") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBeDefined() + }) + + it("should use default model when cache not ready", () => { + const handler = new ClaudeCodeHandler({} as ApiHandlerOptions) + const model = handler.getModel() + + expect(model.id).toBe("claude-sonnet-4-20250514") + expect(model.info).toBeDefined() + }) + + it("should override maxTokens with configured value", async () => { + const handler = new ClaudeCodeHandler({ + claudeCodeMaxOutputTokens: 32000, + } as ApiHandlerOptions) + + // Wait for initialization + await new Promise((resolve) => setTimeout(resolve, 100)) + + const model = handler.getModel() + + expect(model.info.maxTokens).toBe(32000) + }) + }) + + describe("createMessage with alternative providers", () => { + it("should pass environment variables to runClaudeCode for Z.ai", async () => { + const { promises: fs } = await import("fs") + const mockConfig = { + env: { + ANTHROPIC_BASE_URL: "https://api.z.ai/v1", + ANTHROPIC_MODEL: "glm-4.5", + ANTHROPIC_API_KEY: "test-key", + }, + } + + ;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockConfig)) + + // Mock runClaudeCode + const runClaudeCodeModule = await import("../../../integrations/claude-code/run") + vi.spyOn(runClaudeCodeModule, "runClaudeCode").mockImplementation(async function* () { + yield { type: "usage", inputTokens: 100, outputTokens: 50, totalCost: 0.001 } + } as any) + + const handler = new ClaudeCodeHandler({ apiModelId: "glm-4.5" } as ApiHandlerOptions) + // Wait for initialization + await new Promise((resolve) => setTimeout(resolve, 100)) + + const messages = [{ role: "user" as const, content: "test" }] + + const generator = handler.createMessage("system prompt", messages) + const results = [] + for await (const chunk of generator) { + results.push(chunk) + } + + expect(runClaudeCodeModule.runClaudeCode).toHaveBeenCalledWith( + expect.objectContaining({ + envVars: mockConfig.env, + modelId: "glm-4.5", + }), + ) + }) + + it("should use standard Claude model ID when no alternative provider", async () => { + const { promises: fs } = await import("fs") + ;(fs.readFile as Mock).mockRejectedValue(new Error("File not found")) + + // Mock runClaudeCode + const runClaudeCodeModule = await import("../../../integrations/claude-code/run") + vi.spyOn(runClaudeCodeModule, "runClaudeCode").mockImplementation(async function* () { + yield { type: "usage", inputTokens: 100, outputTokens: 50, totalCost: 0.001 } + } as any) + + const handler = new ClaudeCodeHandler({ apiModelId: "claude-sonnet-4-5" } as ApiHandlerOptions) + // Wait for initialization to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const messages = [{ role: "user" as const, content: "test" }] + + const generator = handler.createMessage("system prompt", messages) + const results = [] + for await (const chunk of generator) { + results.push(chunk) + } + + // claude-sonnet-4-5 is a valid ClaudeCodeModelId, so it should be used as-is + expect(runClaudeCodeModule.runClaudeCode).toHaveBeenCalledWith( + expect.objectContaining({ + modelId: "claude-sonnet-4-5", + envVars: {}, + }), + ) + }) + }) +}) diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts index 8d154d794f..be9695a83d 100644 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -24,14 +24,17 @@ describe("ClaudeCodeHandler", () => { vi.clearAllMocks() const options: ApiHandlerOptions = { claudeCodePath: "claude", - apiModelId: "claude-3-5-sonnet-20241022", + apiModelId: "claude-sonnet-4-20250514", } handler = new ClaudeCodeHandler(options) }) - test("should create handler with correct model configuration", () => { + test("should create handler with correct model configuration", async () => { + // Wait for async initialization to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + const model = handler.getModel() - expect(model.id).toBe("claude-3-5-sonnet-20241022") + expect(model.id).toBe("claude-sonnet-4-20250514") expect(model.info.supportsImages).toBe(false) expect(model.info.supportsPromptCache).toBe(true) // Claude Code now supports prompt caching }) @@ -100,15 +103,16 @@ describe("ClaudeCodeHandler", () => { systemPrompt, messages: filteredMessages, path: "claude", - modelId: "claude-3-5-sonnet-20241022", - maxOutputTokens: undefined, // No maxOutputTokens configured in this test + modelId: "claude-sonnet-4-20250514", + maxOutputTokens: undefined, + envVars: {}, }) }) test("should pass maxOutputTokens to runClaudeCode when configured", async () => { const options: ApiHandlerOptions = { claudeCodePath: "claude", - apiModelId: "claude-3-5-sonnet-20241022", + apiModelId: "claude-sonnet-4-20250514", claudeCodeMaxOutputTokens: 16384, } const handlerWithMaxTokens = new ClaudeCodeHandler(options) @@ -136,8 +140,9 @@ describe("ClaudeCodeHandler", () => { systemPrompt, messages: filteredMessages, path: "claude", - modelId: "claude-3-5-sonnet-20241022", + modelId: "claude-sonnet-4-20250514", maxOutputTokens: 16384, + envVars: {}, }) }) @@ -180,11 +185,13 @@ describe("ClaudeCodeHandler", () => { results.push(chunk) } - expect(results).toHaveLength(1) + // Should have reasoning chunk and usage chunk + expect(results).toHaveLength(2) expect(results[0]).toEqual({ type: "reasoning", text: "I need to think about this carefully...", }) + expect(results[1]).toHaveProperty("type", "usage") }) test("should handle redacted thinking content", async () => { @@ -225,11 +232,13 @@ describe("ClaudeCodeHandler", () => { results.push(chunk) } - expect(results).toHaveLength(1) + // Should have reasoning chunk and usage chunk + expect(results).toHaveLength(2) expect(results[0]).toEqual({ type: "reasoning", text: "[Redacted thinking block]", }) + expect(results[1]).toHaveProperty("type", "usage") }) test("should handle mixed content types", async () => { @@ -275,7 +284,8 @@ describe("ClaudeCodeHandler", () => { results.push(chunk) } - expect(results).toHaveLength(2) + // Should have reasoning, text, and usage chunks + expect(results).toHaveLength(3) expect(results[0]).toEqual({ type: "reasoning", text: "Let me think about this...", @@ -284,6 +294,7 @@ describe("ClaudeCodeHandler", () => { type: "text", text: "Here's my response!", }) + expect(results[2]).toHaveProperty("type", "usage") }) test("should handle string chunks from generator", async () => { @@ -305,7 +316,8 @@ describe("ClaudeCodeHandler", () => { results.push(chunk) } - expect(results).toHaveLength(2) + // Should have two text chunks and usage chunk + expect(results).toHaveLength(3) expect(results[0]).toEqual({ type: "text", text: "This is a string chunk", @@ -314,6 +326,7 @@ describe("ClaudeCodeHandler", () => { type: "text", text: "Another string chunk", }) + expect(results[2]).toHaveProperty("type", "usage") }) test("should handle usage and cost tracking with paid usage", async () => { diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index dfafb78aab..2c02b26ad2 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -5,6 +5,9 @@ import { claudeCodeModels, type ModelInfo, getClaudeCodeModelId, + internationalZAiModels, + qwenCodeModels, + deepSeekModels, } from "@roo-code/types" import { type ApiHandler } from ".." import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream" @@ -13,13 +16,196 @@ import { filterMessagesForClaudeCode } from "../../integrations/claude-code/mess import { BaseProvider } from "./base-provider" import { t } from "../../i18n" import { ApiHandlerOptions } from "../../shared/api" +import * as os from "os" +import * as path from "path" +import { promises as fs } from "fs" export class ClaudeCodeHandler extends BaseProvider implements ApiHandler { private options: ApiHandlerOptions + private cachedConfig: any = null + private cachedModelInfo: { id: string; info: ModelInfo } | null = null constructor(options: ApiHandlerOptions) { super() this.options = options + // Initialize model detection asynchronously + this.initializeModelDetection() + } + + /** + * Initialize model detection asynchronously + */ + private async initializeModelDetection(): Promise { + try { + const providerInfo = await this.detectProviderFromConfig() + if (providerInfo) { + const { provider, models } = providerInfo + const config = await this.readClaudeCodeConfig() + const configModelId = config?.env?.ANTHROPIC_MODEL || Object.keys(models)[0] + const finalModelId = this.options.apiModelId || configModelId + + // For alternative providers, always use a valid model from the detected models + const validModelId = finalModelId in models ? finalModelId : Object.keys(models)[0] + + const modelInfo: ModelInfo = { ...models[validModelId] } + + // Override maxTokens with the configured value if provided + if (this.options.claudeCodeMaxOutputTokens !== undefined) { + modelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens + } + + this.cachedModelInfo = { id: validModelId, info: modelInfo } + return + } + // Fall back to standard Claude models + const modelId = this.options.apiModelId || claudeCodeDefaultModelId + if (modelId in claudeCodeModels) { + const id = modelId as ClaudeCodeModelId + const modelInfo: ModelInfo = { ...claudeCodeModels[id] } + + // Override maxTokens with the configured value if provided + if (this.options.claudeCodeMaxOutputTokens !== undefined) { + modelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens + } + + this.cachedModelInfo = { id, info: modelInfo } + } else { + // Use default model + const defaultModelInfo: ModelInfo = { ...claudeCodeModels[claudeCodeDefaultModelId] } + if (this.options.claudeCodeMaxOutputTokens !== undefined) { + defaultModelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens + } + this.cachedModelInfo = { + id: claudeCodeDefaultModelId, + info: defaultModelInfo, + } + } + } catch (error) { + // Fallback to default Claude model on error + const defaultModelInfo: ModelInfo = { ...claudeCodeModels[claudeCodeDefaultModelId] } + if (this.options.claudeCodeMaxOutputTokens !== undefined) { + defaultModelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens + } + this.cachedModelInfo = { + id: claudeCodeDefaultModelId, + info: defaultModelInfo, + } + } + } + + /** + * Static method to get available models based on Claude Code configuration + */ + static async getAvailableModels( + claudeCodePath?: string, + ): Promise<{ provider: string; models: Record } | null> { + try { + // Create a temporary instance to access config reading methods + const tempHandler = new ClaudeCodeHandler({ claudeCodePath } as ApiHandlerOptions) + // Clear any cached config to ensure fresh detection + tempHandler.cachedConfig = null + const providerInfo = await tempHandler.detectProviderFromConfig() + + if (providerInfo) { + // Ensure we always return valid models for alternative providers + const { provider, models } = providerInfo + if (Object.keys(models).length > 0) { + return { provider, models } + } + } + + // Return default Claude models if no alternative provider detected or no models available + return { provider: "claude-code", models: claudeCodeModels } + } catch (error) { + console.error("❌ [ClaudeCodeHandler] Error in getAvailableModels:", error) + // Return default Claude models on error + return { provider: "claude-code", models: claudeCodeModels } + } + } + + /** + * Read Claude Code's native configuration files to detect provider and models + * Checks multiple possible locations in order of priority: + * 1. ~/.claude/settings.json (global user settings) + * 2. ~/.claude/settings.local.json (local user settings) + * 3. ./.claude/settings.json (project-specific settings) + * 4. ./.claude/settings.local.json (project-specific local settings) + * 5. ~/.claude.json (main global config) + */ + private async readClaudeCodeConfig(): Promise { + if (this.cachedConfig) { + return this.cachedConfig + } + + const homeDir = os.homedir() + const currentDir = process.cwd() + + // List of possible configuration file paths in order of priority + const possibleConfigPaths = [ + // Global user settings + path.join(homeDir, ".claude", "settings.json"), + // Local user settings + path.join(homeDir, ".claude", "settings.local.json"), + // Project-specific settings + path.join(currentDir, ".claude", "settings.json"), + // Project-specific local settings + path.join(currentDir, ".claude", "settings.local.json"), + // Main global config + path.join(homeDir, ".claude.json"), + ] + + // Try each path in order + for (const configPath of possibleConfigPaths) { + try { + const configContent = await fs.readFile(configPath, "utf8") + const config = JSON.parse(configContent) + + // Cache the first valid configuration found + this.cachedConfig = config + return config + } catch (error) { + // Continue to the next path if file doesn't exist or can't be read + continue + } + } + + // No valid configuration file found + return null + } + + /** + * Detect alternative provider from Claude Code's configuration + */ + private async detectProviderFromConfig(): Promise<{ provider: string; models: Record } | null> { + const config = await this.readClaudeCodeConfig() + + if (!config || !config.env) { + return null + } + + const baseUrl = config.env.ANTHROPIC_BASE_URL + + if (!baseUrl) { + return null + } + + // Check for Z.ai + if (baseUrl.includes("z.ai")) { + // Return all Z.ai models + return { provider: "zai", models: internationalZAiModels } + } + + // Check for Qwen (Alibaba Cloud/Dashscope) + if (baseUrl.includes("dashscope.aliyuncs.com") || baseUrl.includes("aliyuncs.com")) { + return { provider: "qwen-code", models: qwenCodeModels } + } + + // Check for DeepSeek + if (baseUrl.includes("deepseek.com") || baseUrl.includes("api.deepseek.com")) { + return { provider: "deepseek", models: deepSeekModels } + } + + return null } override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { @@ -29,15 +215,47 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler { const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1" const model = this.getModel() - // Validate that the model ID is a valid ClaudeCodeModelId - const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId + // Check if we're using an alternative provider from Claude Code config + const config = await this.readClaudeCodeConfig() + const envVars = config?.env || {} + const baseUrl = config?.env?.ANTHROPIC_BASE_URL + + // Detect if we're using an alternative provider + const isAlternativeProvider = + baseUrl && + (baseUrl.includes("z.ai") || + baseUrl.includes("dashscope.aliyuncs.com") || + baseUrl.includes("aliyuncs.com") || + baseUrl.includes("deepseek.com") || + baseUrl.includes("api.deepseek.com")) + + let finalModelId: string = model.id + if (isAlternativeProvider) { + // For alternative providers, use the model ID as-is from config or fallback + finalModelId = envVars.ANTHROPIC_MODEL || model.id + } else { + // Validate that the model ID is a valid ClaudeCodeModelId for standard Claude + finalModelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId + } + + let modelIdForClaudeCode: string + if (isAlternativeProvider) { + // For alternative providers, use the model ID as-is + modelIdForClaudeCode = finalModelId + } else { + // For standard Claude, validate the model ID and apply Vertex formatting if needed + const validClaudeModelId = + finalModelId in claudeCodeModels ? (finalModelId as ClaudeCodeModelId) : claudeCodeDefaultModelId + modelIdForClaudeCode = getClaudeCodeModelId(validClaudeModelId, useVertex) + } const claudeProcess = runClaudeCode({ systemPrompt, messages: filteredMessages, path: this.options.claudeCodePath, - modelId: getClaudeCodeModelId(modelId, useVertex), + modelId: modelIdForClaudeCode, maxOutputTokens: this.options.claudeCodeMaxOutputTokens, + envVars, }) // Usage is included with assistant messages, @@ -132,29 +350,32 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler { if (chunk.type === "result" && "result" in chunk) { usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0 - - yield usage } } + + // Always yield usage at the end, even if no result chunk was received + // If totalCost is not set (no result chunk), calculate it based on usage for paid usage + if (usage.totalCost === undefined && isPaidUsage && usage.inputTokens > 0) { + // For paid usage without result chunk, calculate cost based on current model's pricing + const model = this.getModel() + const inputCost = (usage.inputTokens / 1_000_000) * (model.info.inputPrice || 0) + const outputCost = (usage.outputTokens / 1_000_000) * (model.info.outputPrice || 0) + usage.totalCost = inputCost + outputCost + } else if (usage.totalCost === undefined) { + // For free usage or no usage data, cost is 0 + usage.totalCost = 0 + } + yield usage } getModel() { - const modelId = this.options.apiModelId - if (modelId && modelId in claudeCodeModels) { - const id = modelId as ClaudeCodeModelId - const modelInfo: ModelInfo = { ...claudeCodeModels[id] } - - // Override maxTokens with the configured value if provided - if (this.options.claudeCodeMaxOutputTokens !== undefined) { - modelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens - } - - return { id, info: modelInfo } + // Return cached model info, or fallback to default if not yet initialized + if (this.cachedModelInfo) { + return this.cachedModelInfo } + // Fallback to default Claude model if cache is not ready const defaultModelInfo: ModelInfo = { ...claudeCodeModels[claudeCodeDefaultModelId] } - - // Override maxTokens with the configured value if provided if (this.options.claudeCodeMaxOutputTokens !== undefined) { defaultModelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index af5f9925c3..7f3cd5a3de 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -770,6 +770,19 @@ export const webviewMessageHandler = async ( lmstudio: {}, } + // Check for claude-code alternative provider models + try { + const { ClaudeCodeHandler } = await import("../../api/providers/claude-code") + const claudeCodeModels = await ClaudeCodeHandler.getAvailableModels(apiConfiguration.claudeCodePath) + if (claudeCodeModels && claudeCodeModels.provider !== "claude-code") { + // We have alternative provider models, add them to routerModels + // Use a special key to pass them to the frontend + ;(routerModels as any)["claude-code"] = claudeCodeModels.models + } + } catch (error) { + console.debug("Failed to get claude-code models:", error) + } + const safeGetModels = async (options: GetModelsOptions): Promise => { try { return await getModels(options) @@ -966,11 +979,29 @@ export const webviewMessageHandler = async ( case "openMention": openMention(getCurrentCwd(), message.text) break - case "openExternal": - if (message.url) { - vscode.env.openExternal(vscode.Uri.parse(message.url)) + case "openExternal": { + const url = message.url + if (typeof url === "string") { + try { + const uri = vscode.Uri.parse(url) + const isAllowedScheme = uri.scheme === "http" || uri.scheme === "https" + if (isAllowedScheme) { + await vscode.env.openExternal(uri) + } else { + console.warn(`Blocked external URL with disallowed scheme: ${url}`) + vscode.window.showErrorMessage( + t("common:errors.invalid_url_scheme") || "Invalid URL scheme. Only http/https are allowed.", + ) + } + } catch (error) { + console.error("Failed to open external URL:", error) + vscode.window.showErrorMessage( + t("common:errors.open_external_failed") || "Failed to open external URL", + ) + } } break + } case "checkpointDiff": const result = checkoutDiffPayloadSchema.safeParse(message.payload) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts index 1d617b9242..707a151a00 100644 --- a/src/integrations/claude-code/run.ts +++ b/src/integrations/claude-code/run.ts @@ -17,6 +17,7 @@ type ClaudeCodeOptions = { messages: Anthropic.Messages.MessageParam[] path?: string modelId?: string + envVars?: Record } type ProcessState = { @@ -27,7 +28,7 @@ type ProcessState = { } export async function* runClaudeCode( - options: ClaudeCodeOptions & { maxOutputTokens?: number }, + options: ClaudeCodeOptions & { maxOutputTokens?: number; envVars?: Record }, ): AsyncGenerator { const claudePath = options.path || "claude" let process @@ -152,7 +153,8 @@ function runProcess({ path, modelId, maxOutputTokens, -}: ClaudeCodeOptions & { maxOutputTokens?: number }) { + envVars, +}: ClaudeCodeOptions & { maxOutputTokens?: number; envVars?: Record }) { const claudePath = path || "claude" const isWindows = os.platform() === "win32" @@ -185,6 +187,7 @@ function runProcess({ stderr: "pipe", env: { ...process.env, + ...envVars, // Merge in any environment variables from Claude Code config // Use the configured value, or the environment variable, or default to CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS CLAUDE_CODE_MAX_OUTPUT_TOKENS: maxOutputTokens?.toString() || diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 1aa109d9de..ed132421cb 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -258,6 +258,20 @@ const ApiOptions = ({ }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage]) const selectedProviderModels = useMemo(() => { + // Check for dynamic models from claude-code alternative providers + // Use type assertion since claude-code is not part of RouterModels type but we add it dynamically + const routerModelsWithClaudeCode = routerModels as any + if (selectedProvider === "claude-code" && routerModelsWithClaudeCode?.["claude-code"]) { + const dynamicModels = routerModelsWithClaudeCode["claude-code"] + if (Object.keys(dynamicModels).length > 0) { + // Use dynamic models from alternative providers + return Object.keys(dynamicModels).map((modelId) => ({ + value: modelId, + label: modelId, + })) + } + } + const models = MODELS_BY_PROVIDER[selectedProvider] if (!models) return [] @@ -271,7 +285,7 @@ const ApiOptions = ({ : [] return modelOptions - }, [selectedProvider, organizationAllowList]) + }, [selectedProvider, organizationAllowList, routerModels]) const onProviderChange = useCallback( (value: ProviderName) => { @@ -428,7 +442,11 @@ const ApiOptions = ({ {docs && (
- + {t("settings:providers.providerDocumentation", { provider: docs.name })}