Skip to content

Commit 4d02e15

Browse files
authored
🤖 feat: allow Anthropic models to work with env vars (#768)
## Summary Enables Anthropic models to work when credentials are configured via environment variables instead of requiring explicit `providers.jsonc` configuration. ### Changes - Remove mandatory `apiKey` check in `providers.jsonc` for Anthropic provider - Support both `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` env vars - Auto-normalize base URLs to include `/v1` suffix (SDK expects it) - Return structured `api_key_not_found` error when no credentials exist anywhere ### Credential Resolution Order 1. `providers.jsonc` → `anthropic.apiKey` 2. `ANTHROPIC_API_KEY` env var (SDK reads automatically) 3. `ANTHROPIC_AUTH_TOKEN` env var (passed explicitly) ### Base URL Normalization The Anthropic SDK expects base URLs to end with `/v1`. Users often configure URLs without this suffix, so we now auto-append it: - `https://api.anthropic.com` → `https://api.anthropic.com/v1` - `https://api.anthropic.com/v1` → unchanged _Generated with `mux`_
1 parent 55f896b commit 4d02e15

File tree

5 files changed

+144
-9
lines changed

5 files changed

+144
-9
lines changed

docs/models.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,27 @@ Best supported provider with full feature support:
1515
- `anthropic:claude-sonnet-4-5`
1616
- `anthropic:claude-opus-4-1`
1717

18+
**Setup:**
19+
20+
Anthropic can be configured via `~/.mux/providers.jsonc` or environment variables:
21+
22+
```jsonc
23+
{
24+
"anthropic": {
25+
"apiKey": "sk-ant-...",
26+
// Optional: custom base URL (mux auto-appends /v1 if missing)
27+
"baseUrl": "https://api.anthropic.com",
28+
},
29+
}
30+
```
31+
32+
Or set environment variables:
33+
34+
- `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` — API key (required if not in providers.jsonc)
35+
- `ANTHROPIC_BASE_URL` — Custom base URL (optional)
36+
37+
**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.
38+
1839
#### OpenAI (Cloud)
1940

2041
GPT-5 family of models:
@@ -258,7 +279,7 @@ All providers are configured in `~/.mux/providers.jsonc`. Example configurations
258279

259280
```jsonc
260281
{
261-
// Required for Anthropic models
282+
// Anthropic: config OR env vars (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL)
262283
"anthropic": {
263284
"apiKey": "sk-ant-...",
264285
},

src/common/utils/providers/ensureProvidersConfig.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const hasAnyConfiguredProvider = (providers: ProvidersConfig | null | undefined)
1616
const buildProvidersFromEnv = (env: NodeJS.ProcessEnv): ProvidersConfig => {
1717
const providers: ProvidersConfig = {};
1818

19-
const anthropicKey = trim(env.ANTHROPIC_API_KEY);
19+
// Check ANTHROPIC_API_KEY first, fall back to ANTHROPIC_AUTH_TOKEN
20+
const anthropicKey = trim(env.ANTHROPIC_API_KEY) || trim(env.ANTHROPIC_AUTH_TOKEN);
2021
if (anthropicKey.length > 0) {
2122
const entry: ProviderConfig = { apiKey: anthropicKey };
2223

@@ -126,7 +127,7 @@ export const ensureProvidersConfig = (
126127
const providersFromEnv = buildProvidersFromEnv(env);
127128
if (!hasAnyConfiguredProvider(providersFromEnv)) {
128129
throw new Error(
129-
"No provider credentials found. Configure providers.jsonc or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GOOGLE_API_KEY."
130+
"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."
130131
);
131132
}
132133

src/node/services/aiService.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// For now, the commandProcessor tests demonstrate our testing approach
44

55
import { describe, it, expect, beforeEach } from "bun:test";
6-
import { AIService } from "./aiService";
6+
import { AIService, normalizeAnthropicBaseURL } from "./aiService";
77
import { HistoryService } from "./historyService";
88
import { PartialService } from "./partialService";
99
import { InitStateManager } from "./initStateManager";
@@ -29,3 +29,50 @@ describe("AIService", () => {
2929
expect(service).toBeInstanceOf(AIService);
3030
});
3131
});
32+
33+
describe("normalizeAnthropicBaseURL", () => {
34+
it("appends /v1 to URLs without it", () => {
35+
expect(normalizeAnthropicBaseURL("https://api.anthropic.com")).toBe(
36+
"https://api.anthropic.com/v1"
37+
);
38+
expect(normalizeAnthropicBaseURL("https://custom-proxy.com")).toBe(
39+
"https://custom-proxy.com/v1"
40+
);
41+
});
42+
43+
it("preserves URLs already ending with /v1", () => {
44+
expect(normalizeAnthropicBaseURL("https://api.anthropic.com/v1")).toBe(
45+
"https://api.anthropic.com/v1"
46+
);
47+
expect(normalizeAnthropicBaseURL("https://custom-proxy.com/v1")).toBe(
48+
"https://custom-proxy.com/v1"
49+
);
50+
});
51+
52+
it("removes trailing slashes before appending /v1", () => {
53+
expect(normalizeAnthropicBaseURL("https://api.anthropic.com/")).toBe(
54+
"https://api.anthropic.com/v1"
55+
);
56+
expect(normalizeAnthropicBaseURL("https://api.anthropic.com///")).toBe(
57+
"https://api.anthropic.com/v1"
58+
);
59+
});
60+
61+
it("removes trailing slash after /v1", () => {
62+
expect(normalizeAnthropicBaseURL("https://api.anthropic.com/v1/")).toBe(
63+
"https://api.anthropic.com/v1"
64+
);
65+
});
66+
67+
it("handles URLs with ports", () => {
68+
expect(normalizeAnthropicBaseURL("http://localhost:8080")).toBe("http://localhost:8080/v1");
69+
expect(normalizeAnthropicBaseURL("http://localhost:8080/v1")).toBe("http://localhost:8080/v1");
70+
});
71+
72+
it("handles URLs with paths that include v1 in the middle", () => {
73+
// This should still append /v1 because the path doesn't END with /v1
74+
expect(normalizeAnthropicBaseURL("https://proxy.com/api/v1-beta")).toBe(
75+
"https://proxy.com/api/v1-beta/v1"
76+
);
77+
});
78+
});

src/node/services/aiService.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,24 @@ function getProviderFetch(providerConfig: ProviderConfig): typeof fetch {
103103
: defaultFetchWithUnlimitedTimeout;
104104
}
105105

106+
/**
107+
* Normalize Anthropic base URL to ensure it ends with /v1 suffix.
108+
*
109+
* The Anthropic SDK expects baseURL to include /v1 (default: https://api.anthropic.com/v1).
110+
* Many users configure base URLs without the /v1 suffix, which causes API calls to fail.
111+
* This function automatically appends /v1 if missing.
112+
*
113+
* @param baseURL - The base URL to normalize (may or may not have /v1)
114+
* @returns The base URL with /v1 suffix
115+
*/
116+
export function normalizeAnthropicBaseURL(baseURL: string): string {
117+
const trimmed = baseURL.replace(/\/+$/, ""); // Remove trailing slashes
118+
if (trimmed.endsWith("/v1")) {
119+
return trimmed;
120+
}
121+
return `${trimmed}/v1`;
122+
}
123+
106124
/**
107125
* Preload AI SDK provider modules to avoid race conditions in concurrent test environments.
108126
* This function loads @ai-sdk/anthropic, @ai-sdk/openai, and ollama-ai-provider-v2 eagerly
@@ -290,17 +308,43 @@ export class AIService extends EventEmitter {
290308

291309
// Handle Anthropic provider
292310
if (providerName === "anthropic") {
293-
// Check for API key in config
294-
if (!providerConfig.apiKey) {
311+
// Anthropic API key can come from:
312+
// 1. providers.jsonc config (providerConfig.apiKey)
313+
// 2. ANTHROPIC_API_KEY env var (SDK reads this automatically)
314+
// 3. ANTHROPIC_AUTH_TOKEN env var (we pass this explicitly since SDK doesn't check it)
315+
// We allow env var passthrough so users don't need explicit config.
316+
317+
const hasApiKeyInConfig = Boolean(providerConfig.apiKey);
318+
const hasApiKeyEnvVar = Boolean(process.env.ANTHROPIC_API_KEY);
319+
const hasAuthTokenEnvVar = Boolean(process.env.ANTHROPIC_AUTH_TOKEN);
320+
321+
// Return structured error if no credentials available anywhere
322+
if (!hasApiKeyInConfig && !hasApiKeyEnvVar && !hasAuthTokenEnvVar) {
295323
return Err({
296324
type: "api_key_not_found",
297325
provider: providerName,
298326
});
299327
}
300328

329+
// If SDK won't find a key (no config, no ANTHROPIC_API_KEY), use ANTHROPIC_AUTH_TOKEN
330+
let configWithApiKey = providerConfig;
331+
if (!hasApiKeyInConfig && !hasApiKeyEnvVar && hasAuthTokenEnvVar) {
332+
configWithApiKey = { ...providerConfig, apiKey: process.env.ANTHROPIC_AUTH_TOKEN };
333+
}
334+
335+
// Normalize base URL to ensure /v1 suffix (SDK expects it).
336+
// Check config first, then fall back to ANTHROPIC_BASE_URL env var.
337+
// We must explicitly pass baseURL to ensure /v1 normalization happens
338+
// (SDK reads env var but doesn't normalize it).
339+
const baseURLFromEnv = process.env.ANTHROPIC_BASE_URL?.trim();
340+
const effectiveBaseURL = configWithApiKey.baseURL ?? baseURLFromEnv;
341+
const normalizedConfig = effectiveBaseURL
342+
? { ...configWithApiKey, baseURL: normalizeAnthropicBaseURL(effectiveBaseURL) }
343+
: configWithApiKey;
344+
301345
// Add 1M context beta header if requested
302346
const use1MContext = muxProviderOptions?.anthropic?.use1MContext;
303-
const existingHeaders = providerConfig.headers;
347+
const existingHeaders = normalizedConfig.headers;
304348
const headers =
305349
use1MContext && existingHeaders
306350
? { ...existingHeaders, "anthropic-beta": "context-1m-2025-08-07" }
@@ -310,7 +354,7 @@ export class AIService extends EventEmitter {
310354

311355
// Lazy-load Anthropic provider to reduce startup time
312356
const { createAnthropic } = await PROVIDER_REGISTRY.anthropic();
313-
const provider = createAnthropic({ ...providerConfig, headers });
357+
const provider = createAnthropic({ ...normalizedConfig, headers });
314358
return Ok(provider(modelId));
315359
}
316360

tests/ipcMain/setup.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ export async function setupWorkspace(
226226
}
227227

228228
/**
229-
* Setup workspace without provider (for API key error tests)
229+
* Setup workspace without provider (for API key error tests).
230+
* Also clears Anthropic env vars to ensure the error check works.
230231
*/
231232
export async function setupWorkspaceWithoutProvider(
232233
branchPrefix?: string,
@@ -241,6 +242,17 @@ export async function setupWorkspaceWithoutProvider(
241242
}> {
242243
const { createTempGitRepo, cleanupTempGitRepo } = await import("./helpers");
243244

245+
// Clear Anthropic env vars to ensure api_key_not_found error is triggered.
246+
// Save original values for restoration in cleanup.
247+
const savedEnvVars = {
248+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
249+
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
250+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
251+
};
252+
delete process.env.ANTHROPIC_API_KEY;
253+
delete process.env.ANTHROPIC_AUTH_TOKEN;
254+
delete process.env.ANTHROPIC_BASE_URL;
255+
244256
// Create dedicated temp git repo for this test unless one is provided
245257
const tempGitRepo = existingRepoPath || (await createTempGitRepo());
246258

@@ -256,23 +268,33 @@ export async function setupWorkspaceWithoutProvider(
256268
const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, branchName);
257269

258270
if (!createResult.success) {
271+
// Restore env vars before throwing
272+
Object.assign(process.env, savedEnvVars);
259273
await cleanupRepo();
260274
throw new Error(`Workspace creation failed: ${createResult.error}`);
261275
}
262276

263277
if (!createResult.metadata.id) {
278+
Object.assign(process.env, savedEnvVars);
264279
await cleanupRepo();
265280
throw new Error("Workspace ID not returned from creation");
266281
}
267282

268283
if (!createResult.metadata.namedWorkspacePath) {
284+
Object.assign(process.env, savedEnvVars);
269285
await cleanupRepo();
270286
throw new Error("Workspace path not returned from creation");
271287
}
272288

273289
env.sentEvents.length = 0;
274290

275291
const cleanup = async () => {
292+
// Restore env vars
293+
for (const [key, value] of Object.entries(savedEnvVars)) {
294+
if (value !== undefined) {
295+
process.env[key] = value;
296+
}
297+
}
276298
await cleanupTestEnvironment(env);
277299
await cleanupRepo();
278300
};

0 commit comments

Comments
 (0)