diff --git a/docs/models.md b/docs/models.md index 5e0ea7745..5fd024607 100644 --- a/docs/models.md +++ b/docs/models.md @@ -15,6 +15,27 @@ Best supported provider with full feature support: - `anthropic:claude-sonnet-4-5` - `anthropic:claude-opus-4-1` +**Setup:** + +Anthropic can be configured via `~/.mux/providers.jsonc` or environment variables: + +```jsonc +{ + "anthropic": { + "apiKey": "sk-ant-...", + // Optional: custom base URL (mux auto-appends /v1 if missing) + "baseUrl": "https://api.anthropic.com", + }, +} +``` + +Or set environment variables: + +- `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` — API key (required if not in providers.jsonc) +- `ANTHROPIC_BASE_URL` — Custom base URL (optional) + +**Note:** Environment variables are read automatically if no config is provided. The `/v1` path suffix is normalized automatically—you can omit it from base URLs. + #### OpenAI (Cloud) GPT-5 family of models: @@ -179,7 +200,7 @@ All providers are configured in `~/.mux/providers.jsonc`. Example configurations ```jsonc { - // Required for Anthropic models + // Anthropic: config OR env vars (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL) "anthropic": { "apiKey": "sk-ant-...", }, diff --git a/src/common/utils/providers/ensureProvidersConfig.ts b/src/common/utils/providers/ensureProvidersConfig.ts index 9ca52bcd4..408747546 100644 --- a/src/common/utils/providers/ensureProvidersConfig.ts +++ b/src/common/utils/providers/ensureProvidersConfig.ts @@ -16,7 +16,8 @@ const hasAnyConfiguredProvider = (providers: ProvidersConfig | null | undefined) const buildProvidersFromEnv = (env: NodeJS.ProcessEnv): ProvidersConfig => { const providers: ProvidersConfig = {}; - const anthropicKey = trim(env.ANTHROPIC_API_KEY); + // Check ANTHROPIC_API_KEY first, fall back to ANTHROPIC_AUTH_TOKEN + const anthropicKey = trim(env.ANTHROPIC_API_KEY) || trim(env.ANTHROPIC_AUTH_TOKEN); if (anthropicKey.length > 0) { const entry: ProviderConfig = { apiKey: anthropicKey }; @@ -126,7 +127,7 @@ export const ensureProvidersConfig = ( const providersFromEnv = buildProvidersFromEnv(env); if (!hasAnyConfiguredProvider(providersFromEnv)) { throw new Error( - "No provider credentials found. Configure providers.jsonc or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GOOGLE_API_KEY." + "No provider credentials found. Configure providers.jsonc or set ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN) / OPENAI_API_KEY / OPENROUTER_API_KEY / GOOGLE_API_KEY." ); } diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index a2736cff4..af96f3db7 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -3,7 +3,7 @@ // For now, the commandProcessor tests demonstrate our testing approach import { describe, it, expect, beforeEach } from "bun:test"; -import { AIService } from "./aiService"; +import { AIService, normalizeAnthropicBaseURL } from "./aiService"; import { HistoryService } from "./historyService"; import { PartialService } from "./partialService"; import { InitStateManager } from "./initStateManager"; @@ -29,3 +29,50 @@ describe("AIService", () => { expect(service).toBeInstanceOf(AIService); }); }); + +describe("normalizeAnthropicBaseURL", () => { + it("appends /v1 to URLs without it", () => { + expect(normalizeAnthropicBaseURL("https://api.anthropic.com")).toBe( + "https://api.anthropic.com/v1" + ); + expect(normalizeAnthropicBaseURL("https://custom-proxy.com")).toBe( + "https://custom-proxy.com/v1" + ); + }); + + it("preserves URLs already ending with /v1", () => { + expect(normalizeAnthropicBaseURL("https://api.anthropic.com/v1")).toBe( + "https://api.anthropic.com/v1" + ); + expect(normalizeAnthropicBaseURL("https://custom-proxy.com/v1")).toBe( + "https://custom-proxy.com/v1" + ); + }); + + it("removes trailing slashes before appending /v1", () => { + expect(normalizeAnthropicBaseURL("https://api.anthropic.com/")).toBe( + "https://api.anthropic.com/v1" + ); + expect(normalizeAnthropicBaseURL("https://api.anthropic.com///")).toBe( + "https://api.anthropic.com/v1" + ); + }); + + it("removes trailing slash after /v1", () => { + expect(normalizeAnthropicBaseURL("https://api.anthropic.com/v1/")).toBe( + "https://api.anthropic.com/v1" + ); + }); + + it("handles URLs with ports", () => { + expect(normalizeAnthropicBaseURL("http://localhost:8080")).toBe("http://localhost:8080/v1"); + expect(normalizeAnthropicBaseURL("http://localhost:8080/v1")).toBe("http://localhost:8080/v1"); + }); + + it("handles URLs with paths that include v1 in the middle", () => { + // This should still append /v1 because the path doesn't END with /v1 + expect(normalizeAnthropicBaseURL("https://proxy.com/api/v1-beta")).toBe( + "https://proxy.com/api/v1-beta/v1" + ); + }); +}); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 6cb748f91..976831728 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -102,6 +102,24 @@ function getProviderFetch(providerConfig: ProviderConfig): typeof fetch { : defaultFetchWithUnlimitedTimeout; } +/** + * Normalize Anthropic base URL to ensure it ends with /v1 suffix. + * + * The Anthropic SDK expects baseURL to include /v1 (default: https://api.anthropic.com/v1). + * Many users configure base URLs without the /v1 suffix, which causes API calls to fail. + * This function automatically appends /v1 if missing. + * + * @param baseURL - The base URL to normalize (may or may not have /v1) + * @returns The base URL with /v1 suffix + */ +export function normalizeAnthropicBaseURL(baseURL: string): string { + const trimmed = baseURL.replace(/\/+$/, ""); // Remove trailing slashes + if (trimmed.endsWith("/v1")) { + return trimmed; + } + return `${trimmed}/v1`; +} + /** * Preload AI SDK provider modules to avoid race conditions in concurrent test environments. * This function loads @ai-sdk/anthropic, @ai-sdk/openai, and ollama-ai-provider-v2 eagerly @@ -288,17 +306,43 @@ export class AIService extends EventEmitter { // Handle Anthropic provider if (providerName === "anthropic") { - // Check for API key in config - if (!providerConfig.apiKey) { + // Anthropic API key can come from: + // 1. providers.jsonc config (providerConfig.apiKey) + // 2. ANTHROPIC_API_KEY env var (SDK reads this automatically) + // 3. ANTHROPIC_AUTH_TOKEN env var (we pass this explicitly since SDK doesn't check it) + // We allow env var passthrough so users don't need explicit config. + + const hasApiKeyInConfig = Boolean(providerConfig.apiKey); + const hasApiKeyEnvVar = Boolean(process.env.ANTHROPIC_API_KEY); + const hasAuthTokenEnvVar = Boolean(process.env.ANTHROPIC_AUTH_TOKEN); + + // Return structured error if no credentials available anywhere + if (!hasApiKeyInConfig && !hasApiKeyEnvVar && !hasAuthTokenEnvVar) { return Err({ type: "api_key_not_found", provider: providerName, }); } + // If SDK won't find a key (no config, no ANTHROPIC_API_KEY), use ANTHROPIC_AUTH_TOKEN + let configWithApiKey = providerConfig; + if (!hasApiKeyInConfig && !hasApiKeyEnvVar && hasAuthTokenEnvVar) { + configWithApiKey = { ...providerConfig, apiKey: process.env.ANTHROPIC_AUTH_TOKEN }; + } + + // Normalize base URL to ensure /v1 suffix (SDK expects it). + // Check config first, then fall back to ANTHROPIC_BASE_URL env var. + // We must explicitly pass baseURL to ensure /v1 normalization happens + // (SDK reads env var but doesn't normalize it). + const baseURLFromEnv = process.env.ANTHROPIC_BASE_URL?.trim(); + const effectiveBaseURL = configWithApiKey.baseURL ?? baseURLFromEnv; + const normalizedConfig = effectiveBaseURL + ? { ...configWithApiKey, baseURL: normalizeAnthropicBaseURL(effectiveBaseURL) } + : configWithApiKey; + // Add 1M context beta header if requested const use1MContext = muxProviderOptions?.anthropic?.use1MContext; - const existingHeaders = providerConfig.headers; + const existingHeaders = normalizedConfig.headers; const headers = use1MContext && existingHeaders ? { ...existingHeaders, "anthropic-beta": "context-1m-2025-08-07" } @@ -308,7 +352,7 @@ export class AIService extends EventEmitter { // Lazy-load Anthropic provider to reduce startup time const { createAnthropic } = await PROVIDER_REGISTRY.anthropic(); - const provider = createAnthropic({ ...providerConfig, headers }); + const provider = createAnthropic({ ...normalizedConfig, headers }); return Ok(provider(modelId)); } diff --git a/tests/ipcMain/setup.ts b/tests/ipcMain/setup.ts index b206fed68..77e4cc1ca 100644 --- a/tests/ipcMain/setup.ts +++ b/tests/ipcMain/setup.ts @@ -226,7 +226,8 @@ export async function setupWorkspace( } /** - * Setup workspace without provider (for API key error tests) + * Setup workspace without provider (for API key error tests). + * Also clears Anthropic env vars to ensure the error check works. */ export async function setupWorkspaceWithoutProvider( branchPrefix?: string, @@ -241,6 +242,17 @@ export async function setupWorkspaceWithoutProvider( }> { const { createTempGitRepo, cleanupTempGitRepo } = await import("./helpers"); + // Clear Anthropic env vars to ensure api_key_not_found error is triggered. + // Save original values for restoration in cleanup. + const savedEnvVars = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + }; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_BASE_URL; + // Create dedicated temp git repo for this test unless one is provided const tempGitRepo = existingRepoPath || (await createTempGitRepo()); @@ -256,16 +268,20 @@ export async function setupWorkspaceWithoutProvider( const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, branchName); if (!createResult.success) { + // Restore env vars before throwing + Object.assign(process.env, savedEnvVars); await cleanupRepo(); throw new Error(`Workspace creation failed: ${createResult.error}`); } if (!createResult.metadata.id) { + Object.assign(process.env, savedEnvVars); await cleanupRepo(); throw new Error("Workspace ID not returned from creation"); } if (!createResult.metadata.namedWorkspacePath) { + Object.assign(process.env, savedEnvVars); await cleanupRepo(); throw new Error("Workspace path not returned from creation"); } @@ -273,6 +289,12 @@ export async function setupWorkspaceWithoutProvider( env.sentEvents.length = 0; const cleanup = async () => { + // Restore env vars + for (const [key, value] of Object.entries(savedEnvVars)) { + if (value !== undefined) { + process.env[key] = value; + } + } await cleanupTestEnvironment(env); await cleanupRepo(); };