diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index b1aff3ce0f7..424c3f8bc07 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -295,6 +295,58 @@ describe("patchHermesInferenceConfig", () => { }); expect(config.terminal).toEqual({ backend: "local" }); }); + + it("propagates the anthropic wire mode for anthropic-prod routes", () => { + const config: ConfigObject = { + model: { + default: "nvidia-routed", + provider: "custom", + base_url: "https://inference.local/v1", + }, + }; + + const result = patchHermesInferenceConfig(config, "anthropic-prod", "claude-sonnet-4-6"); + + expect(result.changed).toBe(true); + expect(config.model).toEqual({ + default: "claude-sonnet-4-6", + provider: "custom", + base_url: "https://inference.local", + api_mode: "anthropic_messages", + }); + }); + + it("propagates the anthropic wire mode for compatible-anthropic-endpoint routes", () => { + const config: ConfigObject = { model: {} }; + + patchHermesInferenceConfig(config, "compatible-anthropic-endpoint", "claude-sonnet-4-6"); + + expect(config.model).toEqual({ + default: "claude-sonnet-4-6", + provider: "custom", + base_url: "https://inference.local", + api_mode: "anthropic_messages", + }); + }); + + it("clears a stale anthropic wire mode when switching to an OpenAI-wire route", () => { + const config: ConfigObject = { + model: { + default: "claude-sonnet-4-6", + provider: "custom", + base_url: "https://inference.local", + api_mode: "anthropic_messages", + }, + }; + + patchHermesInferenceConfig(config, "hermes-provider", "openai/gpt-5.4-mini"); + + expect(config.model).toEqual({ + default: "openai/gpt-5.4-mini", + provider: "custom", + base_url: "https://inference.local/v1", + }); + }); }); describe("runInferenceSet", () => { diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 447cbf68bc2..099a1fb8449 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -270,6 +270,19 @@ export function patchHermesInferenceConfig( modelConfig.default = model; modelConfig.base_url = route.inferenceBaseUrl; modelConfig.provider = "custom"; + // Hermes "custom" providers default to the OpenAI chat-completions wire and + // pick other transports only from explicit config or base-url heuristics — + // "https://inference.local/v1" matches none. Anthropic-wire routes + // (anthropic-prod, compatible-anthropic-endpoint) must therefore propagate + // the API mode, or the agent POSTs /chat/completions at an anthropic-type + // provider and the gateway L7 policy denies every inference call. Clear the + // key on non-anthropic routes so a stale wire mode never survives a provider + // switch (mirrors Hermes's own stale-api_mode cleanup on provider changes). + if (route.inferenceApi === "anthropic-messages") { + modelConfig.api_mode = "anthropic_messages"; + } else { + delete modelConfig.api_mode; + } return { changed: before !== JSON.stringify(config), route }; } diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index 4e0f3165baa..0d43dbb2a8b 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -271,6 +271,46 @@ describe("getSandboxInferenceConfig", () => { }); }); + it("maps anthropic-prod to the anthropic wire behind inference.local", () => { + expect(getSandboxInferenceConfig("claude-sonnet-4-6", "anthropic-prod")).toEqual({ + providerKey: "anthropic", + primaryModelRef: "anthropic/claude-sonnet-4-6", + inferenceBaseUrl: "https://inference.local", + inferenceApi: "anthropic-messages", + inferenceCompat: null, + }); + }); + + it("defaults compatible-anthropic-endpoint to the anthropic wire when no API preference is baked", () => { + expect(getSandboxInferenceConfig("claude-sonnet-4-6", "compatible-anthropic-endpoint")).toEqual( + { + providerKey: "anthropic", + primaryModelRef: "anthropic/claude-sonnet-4-6", + inferenceBaseUrl: "https://inference.local", + inferenceApi: "anthropic-messages", + inferenceCompat: null, + }, + ); + }); + + it("keeps compatible-anthropic-endpoint on the OpenAI wire only for an explicit preference", () => { + expect( + getSandboxInferenceConfig( + "claude-sonnet-4-6", + "compatible-anthropic-endpoint", + "openai-completions", + ), + ).toEqual({ + providerKey: MANAGED_PROVIDER_ID, + primaryModelRef: `${MANAGED_PROVIDER_ID}/claude-sonnet-4-6`, + inferenceBaseUrl: INFERENCE_ROUTE_URL, + inferenceApi: "openai-completions", + inferenceCompat: { + supportsStore: false, + }, + }); + }); + it("maps OpenAI-compatible endpoints to the managed inference provider", () => { expect(getSandboxInferenceConfig("deepseek-ai/DeepSeek-V4-Flash", "compatible-endpoint")) .toEqual({ diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index c59f56f7274..b153f102905 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -194,7 +194,16 @@ export function getSandboxInferenceConfig( break; case "anthropic-prod": case "compatible-anthropic-endpoint": - if (provider === "compatible-anthropic-endpoint" && inferenceApi === "openai-completions") { + // Only an EXPLICIT openai-completions preference keeps an + // Anthropic-compatible endpoint on the OpenAI wire. The fallback default + // ("openai-completions" when preferredInferenceApi is null) must not: + // callers without a baked preference — notably the Hermes config sync, + // which never passes one — would otherwise speak the OpenAI protocol at + // an anthropic-type provider and be denied by the gateway L7 policy. + if ( + provider === "compatible-anthropic-endpoint" && + preferredInferenceApi === "openai-completions" + ) { providerKey = MANAGED_PROVIDER_ID; primaryModelRef = `${MANAGED_PROVIDER_ID}/${model}`; inferenceCompat = {