feat: extend Claude Code support for alternative providers (Z.ai, Qwen, DeepSeek)

- Add configuration file reading from multiple locations (~/.claude/settings.json, etc.)
- Implement provider detection based on ANTHROPIC_BASE_URL environment variable
- Support dynamic model selection for Z.ai, Qwen (Alibaba Cloud), and DeepSeek providers
- Pass environment variables from Claude Code config to subprocess
- Update frontend to display alternative provider models dynamically
- Add comprehensive test coverage for new functionality
- Update existing tests to handle async initialization

Fixes #8452
This commit is contained in:
Roo Code 2025-10-02 06:47:04 +00:00
parent 13534cc8b7
commit 6c5d5d242b
6 changed files with 652 additions and 32 deletions

View file

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

View file

@ -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 () => {

View file

@ -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<void> {
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<string, ModelInfo> } | 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<any> {
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<string, ModelInfo> } | 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
}

View file

@ -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<ModelRecord> => {
try {
return await getModels(options)

View file

@ -17,6 +17,7 @@ type ClaudeCodeOptions = {
messages: Anthropic.Messages.MessageParam[]
path?: string
modelId?: string
envVars?: Record<string, string>
}
type ProcessState = {
@ -27,7 +28,7 @@ type ProcessState = {
}
export async function* runClaudeCode(
options: ClaudeCodeOptions & { maxOutputTokens?: number },
options: ClaudeCodeOptions & { maxOutputTokens?: number; envVars?: Record<string, string> },
): AsyncGenerator<ClaudeCodeMessage | string> {
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<string, string> }) {
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() ||

View file

@ -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) => {