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 = ({ )} - {selectedProviderModels.length > 0 && ( - <> -
- - -
+ {/* Don't show model selector for Anthropic when using custom base URL - it's handled in the Anthropic component */} + {selectedProviderModels.length > 0 && + !(selectedProvider === "anthropic" && apiConfiguration?.anthropicBaseUrl) && ( + <> +
+ + +
- {/* Show error if a deprecated model is selected */} - {selectedModelInfo?.deprecated && ( - - )} + {/* Show error if a deprecated model is selected */} + {selectedModelInfo?.deprecated && ( + + )} - {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( - - )} + {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( + + )} - {/* Only show model info if not deprecated */} - {!selectedModelInfo?.deprecated && ( - - )} - - )} + {/* Only show model info if not deprecated */} + {!selectedModelInfo?.deprecated && ( + + )} + + )} { const { t } = useAppTranslation() const selectedModel = useSelectedModel(apiConfiguration) + const { organizationAllowList } = useExtensionState() const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) @@ -36,6 +40,10 @@ export const Anthropic = ({ apiConfiguration, setApiConfigurationField }: Anthro [setApiConfigurationField], ) + // When using custom base URL, show ModelPicker to allow custom models + // Otherwise, the model selection is handled by ApiOptions.tsx + const showModelPicker = anthropicBaseUrlSelected + return ( <> {t("settings:providers.anthropicUseAuthToken")} +
+ When using a custom base URL, you can use any model ID including custom models from services + like z.ai +
)} + {showModelPicker && ( + + )} {supports1MContextBeta && (