diff --git a/docs/models/model-mapping.md b/docs/models/model-mapping.md index 88b55333..72d5cf16 100644 --- a/docs/models/model-mapping.md +++ b/docs/models/model-mapping.md @@ -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. --- @@ -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. --- @@ -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) diff --git a/docs/settings-reference.md b/docs/settings-reference.md index ee056596..6cf2ae2f 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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) @@ -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"` @@ -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 @@ -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: @@ -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. --- diff --git a/packages/cli/src/claude-runner.test.ts b/packages/cli/src/claude-runner.test.ts new file mode 100644 index 00000000..5b675938 --- /dev/null +++ b/packages/cli/src/claude-runner.test.ts @@ -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 { + 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 = {}; + 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 = { + [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(); + }); +}); diff --git a/packages/cli/src/claude-runner.ts b/packages/cli/src/claude-runner.ts index bfcbd377..f8393188 100644 --- a/packages/cli/src/claude-runner.ts +++ b/packages/cli/src/claude-runner.ts @@ -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, 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 { @@ -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 diff --git a/packages/cli/src/default-provider.test.ts b/packages/cli/src/default-provider.test.ts index ff462751..e7e5a4b8 100644 --- a/packages/cli/src/default-provider.test.ts +++ b/packages/cli/src/default-provider.test.ts @@ -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", @@ -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", diff --git a/packages/cli/src/default-provider.ts b/packages/cli/src/default-provider.ts index 3647d5d9..fcf58d84 100644 --- a/packages/cli/src/default-provider.ts +++ b/packages/cli/src/default-provider.ts @@ -11,6 +11,7 @@ */ import type { ClaudishProfileConfig } from "./profile-config.js"; +import { getShortcuts } from "./providers/provider-definitions.js"; export type DefaultProviderSource = | "cli-flag" @@ -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 @@ -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, }; diff --git a/packages/cli/src/providers/auto-route-default-provider.test.ts b/packages/cli/src/providers/auto-route-default-provider.test.ts index 05030953..4c2a1838 100644 --- a/packages/cli/src/providers/auto-route-default-provider.test.ts +++ b/packages/cli/src/providers/auto-route-default-provider.test.ts @@ -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", () => { @@ -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", () => { diff --git a/packages/cli/src/providers/auto-route.ts b/packages/cli/src/providers/auto-route.ts index 2cfc75c0..4163b514 100644 --- a/packages/cli/src/providers/auto-route.ts +++ b/packages/cli/src/providers/auto-route.ts @@ -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. */ @@ -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. @@ -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), + }; } /** diff --git a/packages/cli/src/providers/custom-endpoints-loader.test.ts b/packages/cli/src/providers/custom-endpoints-loader.test.ts index d7543fb4..15ac4b45 100644 --- a/packages/cli/src/providers/custom-endpoints-loader.test.ts +++ b/packages/cli/src/providers/custom-endpoints-loader.test.ts @@ -13,6 +13,8 @@ import { getRuntimeProviders, getRuntimeProfiles, } from "./runtime-providers.js"; +import { resolveModelProvider } from "./provider-resolver.js"; +import { toRemoteProvider } from "./provider-definitions.js"; // Minimal ClaudishProfileConfig stub — only the fields the loader reads. function makeConfig( @@ -59,6 +61,7 @@ describe("custom-endpoints-loader", () => { expect(def?.name).toBe("my-vllm"); expect(def?.transport).toBe("openai"); expect(def?.baseUrl).toBe("http://gpu-box:8000/v1"); + expect(def?.apiKeyEnvVar).toBe(""); expect(def?.isDirectApi).toBe(true); expect(getRuntimeProfiles().get("my-vllm")).toBeDefined(); @@ -87,6 +90,7 @@ describe("custom-endpoints-loader", () => { expect(def?.transport).toBe("litellm"); expect(def?.baseUrl).toBe("https://litellm.corp.example.com"); expect(def?.apiPath).toBe("/v1/chat/completions"); + expect(def?.apiKeyEnvVar).toBe(""); }); test("invalid simple (missing url): not registered, error reported", () => { @@ -160,6 +164,75 @@ describe("custom-endpoints-loader", () => { expect(getRuntimeProviders().get("bad")).toBeUndefined(); }); + test("custom endpoint apiKey satisfies resolver without synthetic env var", () => { + const original = process.env.CUSTOM_MY_VLLM_KEY; + delete process.env.CUSTOM_MY_VLLM_KEY; + + try { + const result = loadCustomEndpoints( + makeConfig({ + "my-vllm": { + kind: "simple", + url: "https://api.example.com/v1", + format: "openai", + apiKey: "stored-key", + }, + }) + ); + + expect(result.registered).toBe(1); + const resolution = resolveModelProvider("my-vllm@llama-3"); + expect(resolution.category).toBe("direct-api"); + expect(resolution.requiredApiKeyEnvVar).toBeNull(); + expect(resolution.apiKeyAvailable).toBe(true); + } finally { + if (original === undefined) { + delete process.env.CUSTOM_MY_VLLM_KEY; + } else { + process.env.CUSTOM_MY_VLLM_KEY = original; + } + } + }); + + test("custom endpoint models restrict handler creation", () => { + const result = loadCustomEndpoints( + makeConfig({ + limited: { + kind: "simple", + url: "https://api.example.com/v1", + format: "openai", + apiKey: "stored-key", + models: ["allowed-model"], + }, + }) + ); + + expect(result.registered).toBe(1); + const def = getRuntimeProviders().get("limited"); + const profile = getRuntimeProfiles().get("limited"); + expect(def).toBeDefined(); + expect(profile).toBeDefined(); + + const provider = toRemoteProvider(def!); + const baseCtx = { + provider, + apiKey: "", + targetModel: "limited@allowed-model", + port: 39999, + sharedOpts: {}, + }; + + const originalError = console.error; + console.error = () => {}; + try { + expect(profile!.createHandler({ ...baseCtx, modelName: "blocked-model" })).toBeNull(); + } finally { + console.error = originalError; + } + + expect(profile!.createHandler({ ...baseCtx, modelName: "allowed-model" })).not.toBeNull(); + }); + describe("resolveCustomEndpointApiKey env var expansion", () => { const ORIGINAL_ENV = process.env.TEST_LOADER_KEY; @@ -192,6 +265,35 @@ describe("custom-endpoints-loader", () => { }); expect(resolved).toBe("literal-value"); }); + + test("missing ${VAR} endpoint is skipped with validation error", () => { + const original = process.env.TEST_LOADER_MISSING_KEY; + delete process.env.TEST_LOADER_MISSING_KEY; + + try { + const result = loadCustomEndpoints( + makeConfig({ + missing: { + kind: "simple", + url: "https://x.example.com/v1", + format: "openai", + apiKey: "${TEST_LOADER_MISSING_KEY}", + }, + }) + ); + + expect(result.registered).toBe(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].name).toBe("missing"); + expect(result.errors[0].message).toContain("resolved to an empty value"); + } finally { + if (original === undefined) { + delete process.env.TEST_LOADER_MISSING_KEY; + } else { + process.env.TEST_LOADER_MISSING_KEY = original; + } + } + }); }); test("idempotent re-registration: calling twice does not double-register", () => { diff --git a/packages/cli/src/providers/custom-endpoints-loader.ts b/packages/cli/src/providers/custom-endpoints-loader.ts index 7d42c721..cafb4d85 100644 --- a/packages/cli/src/providers/custom-endpoints-loader.ts +++ b/packages/cli/src/providers/custom-endpoints-loader.ts @@ -63,8 +63,9 @@ export function loadCustomEndpoints(config: ClaudishProfileConfig): LoadResult { for (const [name, entry] of Object.entries(raw)) { try { const validated = CustomEndpointSchema.parse(entry); + validateResolvedApiKey(name, validated); const def = buildProviderDefinition(name, validated); - const profile = buildProviderProfile(validated); + const profile = buildProviderProfile(name, validated); registerRuntimeProvider(def); registerRuntimeProfile(name, profile); result.registered++; @@ -98,7 +99,7 @@ function buildProviderDefinition( transport: ep.format as TransportType, baseUrl: stripTrailingSlash(ep.url), apiPath: "/chat/completions", - apiKeyEnvVar: `CUSTOM_${sanitizeEnvName(name)}_KEY`, + apiKeyEnvVar: "", apiKeyDescription: `${name} (custom endpoint)`, apiKeyUrl: "", shortcuts: [name], @@ -116,7 +117,7 @@ function buildProviderDefinition( transport: ep.transport as TransportType, baseUrl: stripTrailingSlash(ep.baseUrl), apiPath: ep.apiPath ?? "/v1/chat/completions", - apiKeyEnvVar: `CUSTOM_${sanitizeEnvName(name)}_KEY`, + apiKeyEnvVar: "", apiKeyDescription: `${ep.displayName} (custom endpoint)`, apiKeyUrl: "", shortcuts: [name], @@ -133,9 +134,16 @@ function buildProviderDefinition( * Build a ProviderProfile for a custom endpoint that creates a ComposedHandler * on demand. Modeled after litellmProfile in provider-profiles.ts. */ -function buildProviderProfile(ep: CustomEndpoint): ProviderProfile { +function buildProviderProfile(name: string, ep: CustomEndpoint): ProviderProfile { return { createHandler(ctx: ProfileContext): ModelHandler | null { + if (!isAllowedModel(ep, ctx.modelName)) { + console.error( + `[claudish] Custom endpoint '${name}' does not allow model '${ctx.modelName}'.` + ); + return null; + } + const apiKey = resolveCustomEndpointApiKey(ep); if (ep.kind === "simple") { return buildSimpleHandler(ep, ctx, apiKey); @@ -270,10 +278,17 @@ export function resolveCustomEndpointApiKey(ep: CustomEndpoint): string { return process.env[match[1]] ?? ""; } -function stripTrailingSlash(url: string): string { - return url.replace(/\/+$/, ""); +function validateResolvedApiKey(name: string, ep: CustomEndpoint): void { + const apiKey = resolveCustomEndpointApiKey(ep); + if (apiKey.length === 0) { + throw new Error(`apiKey for custom endpoint '${name}' resolved to an empty value`); + } } -function sanitizeEnvName(name: string): string { - return name.toUpperCase().replace(/[^A-Z0-9]/g, "_"); +function isAllowedModel(ep: CustomEndpoint, modelName: string): boolean { + return !ep.models || ep.models.length === 0 || ep.models.includes(modelName); +} + +function stripTrailingSlash(url: string): string { + return url.replace(/\/+$/, ""); } diff --git a/packages/cli/src/proxy-server-routing.test.ts b/packages/cli/src/proxy-server-routing.test.ts new file mode 100644 index 00000000..70ef26f5 --- /dev/null +++ b/packages/cli/src/proxy-server-routing.test.ts @@ -0,0 +1,54 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createProxyServer } from "./proxy-server.js"; +import type { ProxyServer } from "./types.js"; + +const TEST_PORT = 19050 + Math.floor(Math.random() * 100); + +let proxyServer: ProxyServer | null = null; + +async function ensureProxy(): Promise { + if (proxyServer) return proxyServer.port; + proxyServer = await createProxyServer( + TEST_PORT, + "dummy-openrouter-key", + undefined, + false, + undefined, + undefined, + { quiet: true } + ); + return proxyServer.port; +} + +afterAll(async () => { + if (proxyServer) { + await proxyServer.shutdown(); + proxyServer = null; + } +}); + +async function sendMessage(model: string): Promise<{ status: number; body: any }> { + const port = await ensureProxy(); + const res = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + max_tokens: 8, + stream: false, + messages: [{ role: "user", content: "hello" }], + }), + }); + return { status: res.status, body: await res.json() }; +} + +describe("proxy explicit provider routing", () => { + test("unroutable explicit provider returns a routing error instead of falling back", async () => { + const result = await sendMessage("missing-provider@example-model"); + + expect(result.status).toBe(400); + expect(result.body.type).toBe("error"); + expect(result.body.error.type).toBe("invalid_request_error"); + expect(result.body.error.message).toContain('Explicit provider "missing-provider"'); + }); +}); diff --git a/packages/cli/src/proxy-server.ts b/packages/cli/src/proxy-server.ts index 6bfc4c7b..7f7a1398 100644 --- a/packages/cli/src/proxy-server.ts +++ b/packages/cli/src/proxy-server.ts @@ -163,6 +163,23 @@ export async function createProxyServer( return model.startsWith("poe:"); }; + const getExplicitProviderErrorHandler = ( + targetModel: string, + providerName: string + ): ModelHandler => ({ + async handle(c: any): Promise { + return c.json( + wrapAnthropicError( + 400, + `Explicit provider "${providerName}" could not be routed for model "${targetModel}". Check the provider name, API key, endpoint configuration, and model availability.`, + "invalid_request_error" + ), + 400 as any + ); + }, + async shutdown(): Promise {}, + }); + // Helper to get or create Local Provider handler for a target model const getLocalProviderHandler = ( targetModel: string, @@ -473,10 +490,18 @@ export async function createProxyServer( if (localHandler) return localHandler; // 6. Native vs OpenRouter Decision - // Models with explicit provider prefix (@) should never fall to native Anthropic handler. - // They were explicitly routed to a provider - if the handler wasn't created above, - // it's because the API key is missing, not because it's a native model. - const hasExplicitProvider = target.includes("@"); + // Models with explicit provider prefix (@) should never fall through to a different + // provider. If handler creation failed, return a direct routing error instead of + // silently trying OpenRouter. + const parsedExplicitTarget = parseModelSpec(target); + const hasExplicitProvider = parsedExplicitTarget.isExplicitProvider; + if (hasExplicitProvider) { + if (parsedExplicitTarget.provider === "openrouter") { + return getOpenRouterHandler(target, invocationMode); + } + return getExplicitProviderErrorHandler(target, parsedExplicitTarget.provider); + } + const isNative = !target.includes("/") && !hasExplicitProvider; if (isNative) {