diff --git a/src/api/providers/__tests__/anthropic.custom-models.spec.ts b/src/api/providers/__tests__/anthropic.custom-models.spec.ts
new file mode 100644
index 0000000000..973262ce5f
--- /dev/null
+++ b/src/api/providers/__tests__/anthropic.custom-models.spec.ts
@@ -0,0 +1,159 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { Anthropic } from "@anthropic-ai/sdk"
+
+import { AnthropicHandler } from "../anthropic"
+import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
+
+// Mock the Anthropic SDK
+vi.mock("@anthropic-ai/sdk", () => ({
+ Anthropic: vi.fn().mockImplementation(() => ({
+ messages: {
+ create: vi.fn(),
+ countTokens: vi.fn(),
+ },
+ })),
+}))
+
+describe("AnthropicHandler - Custom Models", () => {
+ let handler: AnthropicHandler
+ let mockClient: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockClient = {
+ messages: {
+ create: vi.fn().mockResolvedValue({
+ content: [{ type: "text", text: "Test response" }],
+ }),
+ countTokens: vi.fn().mockResolvedValue({ input_tokens: 100 }),
+ },
+ }
+ ;(Anthropic as any).mockImplementation(() => mockClient)
+ })
+
+ describe("getModel", () => {
+ it("should use predefined model when no custom base URL is set", () => {
+ handler = new AnthropicHandler({
+ apiKey: "test-key",
+ apiModelId: "claude-3-opus-20240229",
+ } as any)
+
+ const model = handler.getModel()
+
+ expect(model.id).toBe("claude-3-opus-20240229")
+ expect(model.info).toBeDefined()
+ expect(model.info.maxTokens).toBe(4096) // Predefined model's max tokens
+ })
+
+ it("should fallback to default model when invalid model is provided without custom base URL", () => {
+ handler = new AnthropicHandler({
+ apiKey: "test-key",
+ apiModelId: "custom-model-xyz",
+ } as any)
+
+ const model = handler.getModel()
+
+ expect(model.id).toBe("claude-sonnet-4-20250514") // Default model
+ expect(model.info).toBeDefined()
+ })
+
+ it("should allow custom model when custom base URL is set", () => {
+ handler = new AnthropicHandler({
+ apiKey: "test-key",
+ apiModelId: "glm-4.6-cc-max",
+ anthropicBaseUrl: "https://api.z.ai/api/anthropic",
+ } as any)
+
+ const model = handler.getModel()
+
+ expect(model.id).toBe("glm-4.6-cc-max")
+ expect(model.info).toBeDefined()
+ expect(model.info.maxTokens).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) // Default for custom models
+ expect(model.info.contextWindow).toBe(200_000) // Default context window
+ expect(model.info.supportsImages).toBe(false) // Conservative default
+ expect(model.info.supportsPromptCache).toBe(false) // Conservative default
+ })
+
+ it("should still use predefined model info when using known model with custom base URL", () => {
+ handler = new AnthropicHandler({
+ apiKey: "test-key",
+ apiModelId: "claude-3-opus-20240229",
+ anthropicBaseUrl: "https://api.z.ai/api/anthropic",
+ } as any)
+
+ const model = handler.getModel()
+
+ expect(model.id).toBe("claude-3-opus-20240229")
+ expect(model.info.maxTokens).toBe(4096) // Should use predefined model's settings
+ expect(model.info.supportsImages).toBe(true) // From predefined model
+ expect(model.info.supportsPromptCache).toBe(true) // From predefined model
+ })
+
+ it("should handle custom models with special characters", () => {
+ handler = new AnthropicHandler({
+ apiKey: "test-key",
+ apiModelId: "glm-4.5v",
+ anthropicBaseUrl: "https://api.z.ai/api/anthropic",
+ } as any)
+
+ const model = handler.getModel()
+
+ expect(model.id).toBe("glm-4.5v")
+ expect(model.info).toBeDefined()
+ })
+
+ it("should use auth token when anthropicUseAuthToken is true", () => {
+ const handler = new AnthropicHandler({
+ apiKey: "test-token",
+ apiModelId: "custom-model",
+ anthropicBaseUrl: "https://api.z.ai/api/anthropic",
+ anthropicUseAuthToken: true,
+ } as any)
+
+ // Verify the Anthropic client was created with authToken instead of apiKey
+ expect(Anthropic).toHaveBeenCalledWith({
+ baseURL: "https://api.z.ai/api/anthropic",
+ authToken: "test-token",
+ })
+ })
+ })
+
+ describe("completePrompt with custom models", () => {
+ it("should use custom model ID when making API calls", async () => {
+ handler = new AnthropicHandler({
+ apiKey: "test-key",
+ apiModelId: "glm-4.6-cc-max",
+ anthropicBaseUrl: "https://api.z.ai/api/anthropic",
+ } as any)
+
+ await handler.completePrompt("Test prompt")
+
+ expect(mockClient.messages.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: "glm-4.6-cc-max",
+ messages: [{ role: "user", content: "Test prompt" }],
+ }),
+ )
+ })
+ })
+
+ describe("countTokens with custom models", () => {
+ it("should use custom model ID for token counting", async () => {
+ handler = new AnthropicHandler({
+ apiKey: "test-key",
+ apiModelId: "glm-4.6-cc-max",
+ anthropicBaseUrl: "https://api.z.ai/api/anthropic",
+ } as any)
+
+ const content = [{ type: "text" as const, text: "Test content" }]
+ await handler.countTokens(content)
+
+ expect(mockClient.messages.countTokens).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: "glm-4.6-cc-max",
+ messages: [{ role: "user", content }],
+ }),
+ )
+ })
+ })
+})
diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts
index 3fb60c0e4f..b3efd2ea40 100644
--- a/src/api/providers/anthropic.ts
+++ b/src/api/providers/anthropic.ts
@@ -245,21 +245,48 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
getModel() {
const modelId = this.options.apiModelId
- let id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId
- let info: ModelInfo = anthropicModels[id]
-
- // If 1M context beta is enabled for Claude Sonnet 4 or 4.5, update the model info
- if ((id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5") && this.options.anthropicBeta1MContext) {
- // Use the tier pricing for 1M context
- const tier = info.tiers?.[0]
- if (tier) {
- info = {
- ...info,
- contextWindow: tier.contextWindow,
- inputPrice: tier.inputPrice,
- outputPrice: tier.outputPrice,
- cacheWritesPrice: tier.cacheWritesPrice,
- cacheReadsPrice: tier.cacheReadsPrice,
+
+ // When using a custom base URL, allow any model ID to be used
+ // This enables compatibility with services like z.ai that provide
+ // Anthropic-compatible endpoints with custom models
+ const isUsingCustomBaseUrl = !!this.options.anthropicBaseUrl
+
+ let id: string
+ let info: ModelInfo
+
+ if (isUsingCustomBaseUrl && modelId && !(modelId in anthropicModels)) {
+ // Custom model with custom base URL - use the model ID as-is
+ // and provide default model info since we don't know the specifics
+ id = modelId
+ info = {
+ maxTokens: ANTHROPIC_DEFAULT_MAX_TOKENS,
+ contextWindow: 200_000, // Default context window
+ supportsImages: false, // Conservative default
+ supportsPromptCache: false, // Conservative default
+ inputPrice: 0, // Unknown pricing
+ outputPrice: 0, // Unknown pricing
+ }
+ } else {
+ // Standard Anthropic model or no custom base URL
+ id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId
+ info = anthropicModels[id as AnthropicModelId]
+
+ // If 1M context beta is enabled for Claude Sonnet 4 or 4.5, update the model info
+ if (
+ (id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5") &&
+ this.options.anthropicBeta1MContext
+ ) {
+ // Use the tier pricing for 1M context
+ const tier = info.tiers?.[0]
+ if (tier) {
+ info = {
+ ...info,
+ contextWindow: tier.contextWindow,
+ inputPrice: tier.inputPrice,
+ outputPrice: tier.outputPrice,
+ cacheWritesPrice: tier.cacheWritesPrice,
+ cacheReadsPrice: tier.cacheReadsPrice,
+ }
}
}
}
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx
index 4142796b66..dc496dce1e 100644
--- a/webview-ui/src/components/settings/ApiOptions.tsx
+++ b/webview-ui/src/components/settings/ApiOptions.tsx
@@ -689,66 +689,68 @@ const ApiOptions = ({