mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
13534cc8b7
commit
6c5d5d242b
6 changed files with 652 additions and 32 deletions
|
|
@ -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: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() ||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue