Skip to content
Draft
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
4 changes: 3 additions & 1 deletion docs/models/model-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Claude Code uses different model "tiers" internally:
- **Subagent** - When Claude spawns child agents

With model mapping, you can route each tier to a different model.
Claudish forwards these mappings to Claude Code using Claude Code's standard role-model environment variables, including `CLAUDE_CODE_SUBAGENT_MODEL` for sub-agents.

---

Expand Down Expand Up @@ -103,6 +104,7 @@ export CLAUDISH_MODEL_SUBAGENT='minimax/minimax-m2'
```

This is especially useful for parallel multi-agent workflows. Cheap models for workers, premium for the orchestrator.
The same mapping can be set with `--model-subagent` or a profile `models.subagent` entry.

---

Expand All @@ -111,7 +113,7 @@ This is especially useful for parallel multi-agent workflows. Cheap models for w
When multiple sources set the same model:

1. **CLI flags** (highest priority)
- `--model-opus`, `--model-sonnet`, etc.
- `--model-opus`, `--model-sonnet`, `--model-haiku`, `--model-subagent`
2. **CLAUDISH_MODEL_*** environment variables
3. **ANTHROPIC_DEFAULT_*** environment variables (lowest)

Expand Down
14 changes: 8 additions & 6 deletions docs/settings-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ The fallback chain is **configurable** via the `defaultProvider` setting. Set it
5. `OPENROUTER_API_KEY` present → OpenRouter
6. Hardcoded `"openrouter"`

Valid values: any built-in provider name (`"openrouter"`, `"litellm"`, `"openai"`, `"anthropic"`, `"google"`) or a custom endpoint name from `customEndpoints`.
Valid values: any built-in provider name (`"openrouter"`, `"litellm"`, `"openai"`, `"anthropic"`, `"google"`) or a custom endpoint name from `customEndpoints`. Provider shortcuts such as `"or"` and `"ll"` are accepted and normalized before routing.

### 6.2 Default chain (no `defaultProvider` set)

Expand Down Expand Up @@ -543,9 +543,9 @@ For OpenAI- or Anthropic-compatible servers:
| `kind` | `"simple"` | yes | Discriminator |
| `url` | string | yes | Base URL of the server |
| `format` | `"openai"` or `"anthropic"` | yes | Wire format |
| `apiKey` | string | no | API key; supports `${VAR}` env expansion |
| `apiKey` | string | yes | API key; supports `${VAR}` env expansion. The configured value is used directly; no extra `CUSTOM_*_KEY` env var is required |
| `modelPrefix` | string | no | Prepended to model name before sending to API |
| `models` | string[] | no | Restrict to listed models; omit to allow any |
| `models` | string[] | no | Restrict to listed unprefixed model names; omit to allow any. Checked before `modelPrefix` is applied |

Usage: `claudish --model my-vllm@llama3.1-70b "task"`

Expand Down Expand Up @@ -580,12 +580,12 @@ Full control over transport, auth, headers, and stream format:
| `transport` | string | yes | Transport type (e.g., `"openai"`, `"anthropic"`) |
| `baseUrl` | string | yes | Server base URL |
| `apiPath` | string | no | Custom API path (overrides default for transport) |
| `apiKey` | string | no | API key; supports `${VAR}` env expansion |
| `apiKey` | string | yes | API key; supports `${VAR}` env expansion. The configured value is used directly; no extra `CUSTOM_*_KEY` env var is required |
| `authScheme` | string | no | Auth header scheme (default: `Bearer`; use `X-Api-Key` for header-name auth) |
| `headers` | object | no | Additional HTTP headers |
| `streamFormat` | string | no | Stream parser override (e.g., `"openai-sse"`, `"anthropic-sse"`) |
| `modelPrefix` | string | no | Prepended to model name |
| `models` | string[] | no | Restrict to listed models |
| `models` | string[] | no | Restrict to listed unprefixed model names; omit to allow any. Checked before `modelPrefix` is applied |

### Environment variable expansion

Expand All @@ -595,6 +595,8 @@ The `apiKey` field supports `${VAR_NAME}` syntax. Claudish expands it from `proc
"apiKey": "${MY_CUSTOM_API_KEY}"
```

If the referenced environment variable is unset or expands to an empty string, the endpoint is skipped with a validation warning instead of being registered with an empty key.

### Validation

Claudish validates all `customEndpoints` entries with Zod at proxy startup. Invalid entries:
Expand All @@ -604,7 +606,7 @@ Claudish validates all `customEndpoints` entries with Zod at proxy startup. Inva

### Runtime registration

Each valid custom endpoint calls `registerRuntimeProvider()` (injects into the provider resolver) and `registerRuntimeProfile()` (injects into the transport layer). The endpoint name becomes a valid provider shortcut immediately.
Each valid custom endpoint calls `registerRuntimeProvider()` (injects into the provider resolver) and `registerRuntimeProfile()` (injects into the transport layer). The endpoint name becomes a valid provider shortcut immediately and can also be used as `defaultProvider` for bare model names.

---

Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/claude-runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import { ENV } from "./config.js";
import { applyModelMappingEnv } from "./claude-runner.js";
import type { ClaudishConfig } from "./types.js";

function makeConfig(overrides: Partial<ClaudishConfig>): ClaudishConfig {
return {
model: undefined,
claudeArgs: [],
interactive: false,
stdin: false,
quiet: true,
monitor: false,
debug: false,
dangerous: false,
logLevel: "info",
jsonOutput: false,
autoApprove: false,
summarizeTools: false,
noLogs: true,
diagMode: "off",
...overrides,
};
}

describe("applyModelMappingEnv", () => {
test("writes resolved role mappings to Claude Code standard env vars", () => {
const env: Record<string, string> = {};
applyModelMappingEnv(
env,
makeConfig({
modelOpus: "meridian@claude-opus-4-7",
modelSonnet: "ds@deepseek-v4-flash",
modelHaiku: "meridian@claude-haiku-4-5",
modelSubagent: "ds@deepseek-v4-pro",
})
);

expect(env[ENV.ANTHROPIC_DEFAULT_OPUS_MODEL]).toBe("meridian@claude-opus-4-7");
expect(env[ENV.ANTHROPIC_DEFAULT_SONNET_MODEL]).toBe("ds@deepseek-v4-flash");
expect(env[ENV.ANTHROPIC_DEFAULT_HAIKU_MODEL]).toBe("meridian@claude-haiku-4-5");
expect(env[ENV.CLAUDE_CODE_SUBAGENT_MODEL]).toBe("ds@deepseek-v4-pro");
});

test("leaves existing env untouched when a role has no mapping", () => {
const env: Record<string, string> = {
[ENV.CLAUDE_CODE_SUBAGENT_MODEL]: "existing-subagent",
};
applyModelMappingEnv(env, makeConfig({ modelHaiku: "meridian@claude-haiku-4-5" }));

expect(env[ENV.ANTHROPIC_DEFAULT_HAIKU_MODEL]).toBe("meridian@claude-haiku-4-5");
expect(env[ENV.CLAUDE_CODE_SUBAGENT_MODEL]).toBe("existing-subagent");
expect(env[ENV.ANTHROPIC_DEFAULT_OPUS_MODEL]).toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions packages/cli/src/claude-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ function hasNativeAnthropicMapping(config: ClaudishConfig): boolean {
return models.some((m) => m && parseModelSpec(m).provider === "native-anthropic");
}

/**
* Propagate resolved Claudish role mappings to Claude Code's standard model env vars.
*
* Claudish also keeps modelMap in the proxy as a compatibility fallback, but Claude
* Code owns sub-agent process selection. In particular, sub-agent routing only works
* reliably when CLAUDE_CODE_SUBAGENT_MODEL is present in the child environment.
*/
export function applyModelMappingEnv(env: Record<string, string>, config: ClaudishConfig): void {
if (config.modelOpus) {
env[ENV.ANTHROPIC_DEFAULT_OPUS_MODEL] = config.modelOpus;
}
if (config.modelSonnet) {
env[ENV.ANTHROPIC_DEFAULT_SONNET_MODEL] = config.modelSonnet;
}
if (config.modelHaiku) {
env[ENV.ANTHROPIC_DEFAULT_HAIKU_MODEL] = config.modelHaiku;
}
if (config.modelSubagent) {
env[ENV.CLAUDE_CODE_SUBAGENT_MODEL] = config.modelSubagent;
}
}

// Use process.platform directly to ensure runtime evaluation
// (module-level constants can be inlined by bundlers at build time)
function isWindows(): boolean {
Expand Down Expand Up @@ -349,6 +371,8 @@ export async function runClaudeWithProxy(
// independent API calls through a proxy (not nesting sessions), this is safe.
delete env.CLAUDECODE;

applyModelMappingEnv(env, config);

// Handle API key and model based on mode
if (config.monitor) {
// Monitor mode: Don't set ANTHROPIC_API_KEY at all
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/default-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ describe("resolveDefaultProvider precedence", () => {
expect(result.legacyAutoPromoted).toBe(false);
});

test("CLI flag shortcut is canonicalized", () => {
const env: NodeJS.ProcessEnv = {};
const config = makeConfig();

const result = resolveDefaultProvider({ cliFlag: "ll", config, env });

expect(result.provider).toBe("litellm");
expect(result.source).toBe("cli-flag");
});

test("env var wins over config and legacy", () => {
const env: NodeJS.ProcessEnv = {
CLAUDISH_DEFAULT_PROVIDER: "from-env",
Expand All @@ -47,6 +57,16 @@ describe("resolveDefaultProvider precedence", () => {
expect(result.legacyAutoPromoted).toBe(false);
});

test("env var shortcut is canonicalized", () => {
const env: NodeJS.ProcessEnv = { CLAUDISH_DEFAULT_PROVIDER: "or" };
const config = makeConfig();

const result = resolveDefaultProvider({ config, env });

expect(result.provider).toBe("openrouter");
expect(result.source).toBe("env-var");
});

test("config wins over legacy", () => {
const env: NodeJS.ProcessEnv = {
LITELLM_BASE_URL: "http://litellm.local",
Expand Down
20 changes: 17 additions & 3 deletions packages/cli/src/default-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import type { ClaudishProfileConfig } from "./profile-config.js";
import { getShortcuts } from "./providers/provider-definitions.js";

export type DefaultProviderSource =
| "cli-flag"
Expand All @@ -35,6 +36,11 @@ export interface ResolveOptions {
env?: NodeJS.ProcessEnv;
}

function normalizeProviderName(provider: string): string {
const normalized = provider.trim().toLowerCase();
return getShortcuts()[normalized] ?? normalized;
}

/**
* Resolve the effective default provider using the precedence chain:
* 1. --default-provider CLI flag
Expand All @@ -49,17 +55,25 @@ export function resolveDefaultProvider(opts: ResolveOptions): ResolvedDefaultPro
const env = opts.env ?? process.env;

if (opts.cliFlag && opts.cliFlag.length > 0) {
return { provider: opts.cliFlag, source: "cli-flag", legacyAutoPromoted: false };
return {
provider: normalizeProviderName(opts.cliFlag),
source: "cli-flag",
legacyAutoPromoted: false,
};
}

const envVal = env.CLAUDISH_DEFAULT_PROVIDER;
if (envVal && envVal.length > 0) {
return { provider: envVal, source: "env-var", legacyAutoPromoted: false };
return {
provider: normalizeProviderName(envVal),
source: "env-var",
legacyAutoPromoted: false,
};
}

if (opts.config.defaultProvider && opts.config.defaultProvider.length > 0) {
return {
provider: opts.config.defaultProvider,
provider: normalizeProviderName(opts.config.defaultProvider),
source: "config-file",
legacyAutoPromoted: false,
};
Expand Down
42 changes: 40 additions & 2 deletions packages/cli/src/providers/auto-route-default-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { getDefaultProviderRoute, getFallbackChain } from "./auto-route.js";
import { loadCustomEndpoints } from "./custom-endpoints-loader.js";
import { clearRuntimeRegistry } from "./runtime-providers.js";

const originalEnv = { ...process.env };

describe("getDefaultProviderRoute", () => {
beforeEach(() => {
process.env = { ...originalEnv };
clearRuntimeRegistry();
});
afterEach(() => {
process.env = { ...originalEnv };
clearRuntimeRegistry();
});

test("returns litellm route when default='litellm' and both LITELLM env vars set", () => {
Expand Down Expand Up @@ -50,17 +54,51 @@ describe("getDefaultProviderRoute", () => {
expect(getDefaultProviderRoute("foo-model", "google")).toBeNull();
});

test("returns null for unknown/custom default provider name", () => {
expect(getDefaultProviderRoute("foo-model", "my-custom-endpoint")).toBeNull();
test("returns null for unknown default provider name", () => {
expect(getDefaultProviderRoute("foo-model", "my-unknown-endpoint")).toBeNull();
});

test("returns custom endpoint route when default provider is registered", () => {
const result = loadCustomEndpoints({
version: "1.0.0",
defaultProfile: "default",
profiles: {},
customEndpoints: {
"my-custom-endpoint": {
kind: "simple",
url: "https://api.example.com/v1",
format: "openai",
apiKey: "stored-key",
},
},
});

expect(result.registered).toBe(1);
const route = getDefaultProviderRoute("foo-model", "my-custom-endpoint");
expect(route).not.toBeNull();
expect(route!.provider).toBe("my-custom-endpoint");
expect(route!.modelSpec).toBe("my-custom-endpoint@foo-model");
expect(route!.displayName).toBe("my-custom-endpoint");
});

test("canonicalizes default provider shortcut before building route", () => {
process.env.LITELLM_BASE_URL = "http://example.invalid:4000";
process.env.LITELLM_API_KEY = "test-key";
const route = getDefaultProviderRoute("foo-model", "ll");
expect(route).not.toBeNull();
expect(route!.provider).toBe("litellm");
expect(route!.modelSpec).toBe("litellm@foo-model");
});
});

describe("getFallbackChain — default provider seeding", () => {
beforeEach(() => {
process.env = { ...originalEnv };
clearRuntimeRegistry();
});
afterEach(() => {
process.env = { ...originalEnv };
clearRuntimeRegistry();
});

test("case 1: default='litellm' with LITELLM env vars puts litellm first", () => {
Expand Down
35 changes: 28 additions & 7 deletions packages/cli/src/providers/auto-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,13 @@ export interface FallbackRoute {
}

import {
getShortestPrefix,
getDisplayName as _getDisplayName,
getAllProviders,
getDisplayName,
getProviderByName,
getShortestPrefix,
getShortcuts,
} from "./provider-definitions.js";
import { getRuntimeProviders } from "./runtime-providers.js";

/** Reverse mapping: canonical provider name → shortest @ prefix for handler creation.
* Derived from BUILTIN_PROVIDERS. */
Expand Down Expand Up @@ -412,14 +415,16 @@ function hasProviderCredentials(provider: string): boolean {
* (e.g., native-API providers — openai/anthropic/google — have their own
* native-API step in {@link getFallbackChain} that handles them).
*
* Phase 2 supports the builtin defaults: litellm, openrouter.
* Custom endpoint defaults are wired in Phase 3.
* Supports builtin defaults plus runtime custom endpoint defaults.
*/
export function getDefaultProviderRoute(
modelName: string,
defaultProvider: string
): FallbackRoute | null {
switch (defaultProvider) {
const normalizedProvider =
getShortcuts()[defaultProvider.toLowerCase()] ?? defaultProvider.toLowerCase();

switch (normalizedProvider) {
case "litellm": {
// Preserves the current implicit behavior — only emits a route when
// both LITELLM env vars are set.
Expand Down Expand Up @@ -452,9 +457,25 @@ export function getDefaultProviderRoute(
return null;
}
default:
// Custom endpoint name — Phase 3 territory. Return null for now.
return null;
break;
}

const def = getProviderByName(normalizedProvider);
if (!def || !def.isDirectApi || !def.baseUrl) {
return null;
}

const isRuntimeProvider = getRuntimeProviders().has(def.name);
if (!isRuntimeProvider && !hasProviderCredentials(def.name)) {
return null;
}

const prefix = getShortestPrefix(def.name);
return {
provider: def.name,
modelSpec: `${prefix}@${modelName}`,
displayName: getDisplayName(def.name),
};
}

/**
Expand Down
Loading