Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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-...",
},
Expand Down
5 changes: 3 additions & 2 deletions src/common/utils/providers/ensureProvidersConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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."
);
}

Expand Down
49 changes: 48 additions & 1 deletion src/node/services/aiService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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"
);
});
});
52 changes: 48 additions & 4 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand All @@ -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));
}

Expand Down
24 changes: 23 additions & 1 deletion tests/ipcMain/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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());

Expand All @@ -256,23 +268,33 @@ 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");
}

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();
};
Expand Down