diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index da1b43b652..38b8b910e3 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -348,6 +348,16 @@ If your local server implements the Anthropic Messages API (`/v1/messages`), cho $$nemoclaw onboard ``` + +For `compatible-anthropic-endpoint`, Hermes uses the managed OpenAI Chat Completions frontend at `https://inference.local/v1`. +During onboarding, NemoClaw verifies that the endpoint also serves `/v1/chat/completions`, then registers that surface with OpenShell as `type=openai` using `OPENAI_BASE_URL`. +The route retains `COMPATIBLE_ANTHROPIC_API_KEY` as its credential binding. +This avoids duplicate Anthropic SSE `message_start` events. +If the endpoint only serves Anthropic Messages, onboarding stops with guidance instead of creating a Hermes sandbox with an unroutable or broken streaming path. +OpenClaw custom Anthropic routes and first-party Anthropic routes remain on the native Anthropic Messages frontend. +AWS Bedrock routes retain their existing OpenAI-compatible adapter behavior. + + For non-interactive setup, use `NEMOCLAW_PROVIDER=anthropicCompatible` and set `COMPATIBLE_ANTHROPIC_API_KEY`. ```bash diff --git a/docs/inference/switch-inference-providers.mdx b/docs/inference/switch-inference-providers.mdx index 4019fcb731..019498139d 100644 --- a/docs/inference/switch-inference-providers.mdx +++ b/docs/inference/switch-inference-providers.mdx @@ -111,8 +111,22 @@ For OpenClaw, `inference set` syncs the provider API family and primary model re For Hermes, `inference set` writes `model.api_mode: anthropic_messages` for Anthropic Messages routes, `model.api_mode: codex_responses` for OpenAI Responses routes, and removes `api_mode` for OpenAI-style chat-completions routes. Hermes also keeps `model.api_key` on the OpenShell proxy placeholder so dashboard and API sessions continue to authenticate through the gateway after a route change. -Amazon Bedrock Runtime routes created through `compatible-anthropic-endpoint` are the exception. -When you switch within the same Bedrock Runtime compatible provider, NemoClaw keeps the route OpenAI-compatible and does not set Hermes to Anthropic Messages mode. + +For `compatible-anthropic-endpoint`, NemoClaw selects the managed OpenAI Chat Completions frontend at `https://inference.local/v1` and verifies the endpoint's `/v1/chat/completions` surface before registering it with OpenShell as `type=openai` using `OPENAI_BASE_URL`. +The route retains `COMPATIBLE_ANTHROPIC_API_KEY` as its credential binding. +Hermes omits `model.api_mode` for this route. +OpenClaw custom Anthropic routes and first-party Anthropic routes remain on the native Anthropic Messages frontend. +AWS Bedrock routes retain their existing OpenAI-compatible adapter behavior. + +To migrate an already affected Hermes sandbox, rebuild it so NemoClaw can verify the OpenAI surface, repair the gateway provider protocol, and recreate the sandbox configuration: + +```bash +$$nemoclaw rebuild +``` + +Resuming onboarding also detects and repairs a stale route. +`inference set` can select this provider after it has been registered on the verified OpenAI surface, but it fails before mutation for a legacy Anthropic registration because that command cannot change a gateway provider's protocol type. + #### Switching from Responses API to Chat Completions diff --git a/src/lib/actions/inference-route-api.test.ts b/src/lib/actions/inference-route-api.test.ts index 8ecc3e3f0e..50a3735088 100644 --- a/src/lib/actions/inference-route-api.test.ts +++ b/src/lib/actions/inference-route-api.test.ts @@ -188,11 +188,56 @@ describe("resolveRuntimeInferenceApi", () => { ).toBe("openai-responses"); }); - it("reads Hermes api_mode for same-provider Hermes switches", () => { + it("reads Hermes api_mode for other same-provider Hermes switches", () => { expect( resolve( { model: { api_mode: "anthropic_messages" } }, - { agentName: "hermes", session: null }, + { + agentName: "hermes", + currentProvider: "compatible-endpoint", + provider: "compatible-endpoint", + session: null, + }, + ), + ).toBe("anthropic-messages"); + }); + + it("keeps Hermes custom Anthropic routes off the managed Anthropic SSE frontend (#6289)", () => { + expect( + resolve( + { model: { api_mode: "anthropic_messages" } }, + { + agentName: "hermes", + session: session({ preferredInferenceApi: "anthropic-messages" }), + }, + ), + ).toBe("openai-completions"); + }); + + it("uses the Hermes override when switching into a custom Anthropic route (#6289)", () => { + expect( + resolve( + {}, + { + agentName: "hermes", + currentProvider: "nvidia-prod", + session: session({ + provider: "nvidia-prod", + preferredInferenceApi: "openai-completions", + }), + }, + ), + ).toBe("openai-completions"); + }); + + it("preserves native Anthropic routing for OpenClaw custom endpoints (#6289)", () => { + expect( + resolve( + { models: { providers: { anthropic: { api: "anthropic-messages" } } } }, + { + agentName: "openclaw", + session: session({ preferredInferenceApi: "anthropic-messages" }), + }, ), ).toBe("anthropic-messages"); }); diff --git a/src/lib/actions/inference-route-api.ts b/src/lib/actions/inference-route-api.ts index cf10464175..14dd5bd05c 100644 --- a/src/lib/actions/inference-route-api.ts +++ b/src/lib/actions/inference-route-api.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getSandboxInferenceConfig } from "../inference/config"; +import { getSandboxInferenceConfig, resolveAgentInferenceApi } from "../inference/config"; import type { ConfigObject } from "../security/credential-filter"; import { isConfigObject } from "../security/credential-filter"; import type { Session } from "../state/onboard-session"; @@ -109,6 +109,8 @@ export function resolveRuntimeInferenceApi(options: { }): InferenceApi | null { const { agentName, config, currentProvider, provider, sandboxName, session } = options; if (provider === "anthropic-prod") return "anthropic-messages"; + const agentApi = resolveAgentInferenceApi(agentName, provider, null); + if (agentApi) return normalizeInferenceApi(agentApi); const sameProvider = currentProvider === provider; const sessionApi = sameProvider ? sessionRouteApi(session, sandboxName, provider) : null; diff --git a/src/lib/actions/inference-set-hermes-run.test.ts b/src/lib/actions/inference-set-hermes-run.test.ts index 8afd64e028..c4f087f4c7 100644 --- a/src/lib/actions/inference-set-hermes-run.test.ts +++ b/src/lib/actions/inference-set-hermes-run.test.ts @@ -104,7 +104,7 @@ describe("runInferenceSet Hermes routing", () => { }); }); - it("syncs Hermes compatible Anthropic switches to Anthropic Messages when changing provider families", async () => { + it("keeps Hermes custom Anthropic switches off the managed Anthropic SSE frontend (#6289)", async () => { const config: ConfigObject = { model: { default: "openai/gpt-5.4-mini", @@ -132,6 +132,17 @@ describe("runInferenceSet Hermes routing", () => { preferredInferenceApi: "anthropic-messages", }), }); + deps.calls.captureOpenshell.mockImplementation((args: string[]) => + args[0] === "provider" && args[1] === "get" + ? { + status: 0, + output: + "Name: compatible-anthropic-endpoint\nType: openai\nCredential keys: COMPATIBLE_ANTHROPIC_API_KEY\nConfig keys: OPENAI_BASE_URL", + stdout: "", + stderr: "", + } + : { status: 0, output: "", stdout: "", stderr: "" }, + ); const result = await runInferenceSet( { @@ -146,9 +157,8 @@ describe("runInferenceSet Hermes routing", () => { expect(config.model).toEqual({ default: "claude-sonnet-proxy", provider: "custom", - base_url: "https://inference.local", + base_url: "https://inference.local/v1", api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, - api_mode: "anthropic_messages", }); // The upstream annotation must track the selected provider together with // the API-family field, so the two cannot drift apart on later switches. @@ -163,18 +173,100 @@ describe("runInferenceSet Hermes routing", () => { model: "claude-sonnet-proxy", endpointUrl: "https://anthropic-compatible.example/v1", credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", - preferredInferenceApi: "anthropic-messages", + preferredInferenceApi: "openai-completions", }), ]); expect(deps.getSession()).toMatchObject({ provider: "compatible-anthropic-endpoint", model: "claude-sonnet-proxy", - preferredInferenceApi: "anthropic-messages", + preferredInferenceApi: "openai-completions", }); expect(result).toMatchObject({ - providerKey: "anthropic", - primaryModelRef: "anthropic/claude-sonnet-proxy", + providerKey: "inference", + primaryModelRef: "inference/claude-sonnet-proxy", + }); + }); + + it("rejects inference set before mutating a legacy Anthropic provider (#6289)", async () => { + const config: ConfigObject = { model: {} }; + const deps = createDeps({ + config, + entry: { + name: "hermes", + agent: "hermes", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + endpointUrl: "https://anthropic-compatible.example/v1", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "openai-completions", + }, + defaultSandbox: "hermes", + target: HERMES_TARGET, + session: baseSession({ agent: "hermes", sandboxName: "hermes" }), + }); + deps.calls.captureOpenshell.mockReturnValue({ + status: 0, + output: + "Name: compatible-anthropic-endpoint\nType: anthropic\nCredential keys: COMPATIBLE_ANTHROPIC_API_KEY\nConfig keys: ANTHROPIC_BASE_URL", + stdout: "", + stderr: "", + }); + + await expect( + runInferenceSet( + { + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + sandboxName: "hermes", + noVerify: true, + }, + deps, + ), + ).rejects.toThrow("Run 'nemoclaw hermes rebuild'"); + + expect(deps.calls.captureOpenshell).toHaveBeenCalledTimes(1); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); + expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); + }); + + it("rejects an explicit Anthropic frontend request for Hermes custom endpoints (#6289)", async () => { + const config: ConfigObject = { + model: { + default: "openai/gpt-5.4-mini", + provider: "custom", + base_url: "https://inference.local/v1", + }, + }; + const deps = createDeps({ + config, + entry: { + name: "hermes", + agent: "hermes", + provider: "hermes-provider", + model: "openai/gpt-5.4-mini", + }, + defaultSandbox: "hermes", + target: HERMES_TARGET, + session: baseSession({ agent: "hermes", sandboxName: "hermes" }), }); + + await expect( + runInferenceSet( + { + provider: "compatible-anthropic-endpoint", + model: "nvidia/nvidia/nemotron-3-super-v3", + sandboxName: "hermes", + noVerify: true, + endpointUrl: "https://inference-api.nvidia.com", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + inferenceApi: "anthropic-messages", + }, + deps, + ), + ).rejects.toThrow("require the managed openai-completions frontend"); + + expect(deps.calls.captureOpenshell).not.toHaveBeenCalled(); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); }); it("preserves same-provider Bedrock Runtime adapter routing for Hermes switches", async () => { @@ -192,6 +284,7 @@ describe("runInferenceSet Hermes routing", () => { agent: "hermes", provider: "compatible-anthropic-endpoint", model: "anthropic.claude-3-5-sonnet-20240620-v1:0", + endpointUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", }, defaultSandbox: "hermes", target: HERMES_TARGET, @@ -200,6 +293,7 @@ describe("runInferenceSet Hermes routing", () => { sandboxName: "hermes", provider: "compatible-anthropic-endpoint", model: "anthropic.claude-3-5-sonnet-20240620-v1:0", + endpointUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", preferredInferenceApi: "openai-completions", }), }); diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 4af3566584..a85a08cfb5 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -5,14 +5,20 @@ import type { CaptureOpenshellOptions, CaptureOpenshellResult } from "../adapter import { captureOpenshell, getOpenshellBinary } from "../adapters/openshell/runtime"; import { CLI_NAME } from "../cli/branding"; import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../hermes-proxy-api-key"; +import { isBedrockRuntimeEndpoint } from "../inference/bedrock-runtime"; import { getProviderSelectionConfig, getSandboxInferenceConfig, + resolveAgentInferenceApi, type SandboxInferenceConfig, } from "../inference/config"; import { resolveContextWindowForModel } from "../inference/context-window"; import { type ValidationResult, validateLocalProvider } from "../inference/local"; import { inferenceSelectionRegistryFields } from "../inference/selection"; +import { + matchesGatewayProviderBinding, + parseGatewayProviderMetadata, +} from "../onboard/gateway-provider-metadata"; import { ensureLocalProviderReachable } from "../onboard/local-inference-topology"; import { type AgentConfigTarget, @@ -399,6 +405,46 @@ function isCustomCompatibleProvider(provider: string): boolean { return provider === "compatible-endpoint" || provider === "compatible-anthropic-endpoint"; } +function assertHermesCompatibleAnthropicOpenAiProvider( + sandboxName: string, + agentName: string, + provider: string, + endpointUrl: string | null, + deps: InferenceSetDeps, +): void { + if ( + agentName !== "hermes" || + provider !== "compatible-anthropic-endpoint" || + isBedrockRuntimeEndpoint(endpointUrl) + ) { + return; + } + + const result = deps.captureOpenshell(["provider", "get", provider], { + ignoreError: true, + includeStreams: true, + maxBuffer: OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER, + }); + const output = result.output || `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + const metadata = result.status === 0 ? parseGatewayProviderMetadata(output) : null; + if ( + matchesGatewayProviderBinding(metadata, { + name: provider, + type: "openai", + credentialKey: "COMPATIBLE_ANTHROPIC_API_KEY", + configKey: "OPENAI_BASE_URL", + }) + ) { + return; + } + + throw new InferenceSetError( + `Hermes requires provider '${provider}' to be registered on its verified OpenAI-compatible surface. ` + + `Run '${CLI_NAME} ${sandboxName} rebuild' to migrate this sandbox, or re-run onboarding for the endpoint before using inference set.`, + 2, + ); +} + function hasExplicitCustomMetadata(options: InferenceSetOptions): boolean { return Boolean(options.endpointUrl || options.credentialEnv || options.inferenceApi); } @@ -632,6 +678,18 @@ async function runInferenceSetWithoutHostLock( deps.rewriteConfigUrlsWithDnsPinning, ); const explicitPreferredInferenceApi = explicitMetadata?.preferredInferenceApi ?? null; + if ( + agentName === "hermes" && + provider === "compatible-anthropic-endpoint" && + explicitPreferredInferenceApi !== null && + explicitPreferredInferenceApi !== "openai-completions" + ) { + throw new InferenceSetError( + "Hermes custom Anthropic endpoints require the managed openai-completions frontend. " + + "Set --inference-api openai-completions or omit --inference-api so NemoClaw selects it.", + 2, + ); + } const registryMetadata = registryMetadataForProviderSwitch({ entry, provider, @@ -670,6 +728,17 @@ async function runInferenceSetWithoutHostLock( } } + // `inference set` changes the selected route but cannot change a gateway + // provider's protocol type. Fail before mutation when a legacy Anthropic + // registration would make the required Hermes OpenAI frontend unroutable. + assertHermesCompatibleAnthropicOpenAiProvider( + sandboxName, + agentName, + provider, + registryMetadata.endpointUrl ?? null, + deps, + ); + deps.log(` Setting OpenShell inference route: ${provider} / ${model}`); const setResult = deps.captureOpenshell( openshellInferenceSetArgs({ provider, model, noVerify: effectiveNoVerify }), @@ -696,7 +765,16 @@ async function runInferenceSetWithoutHostLock( nimContainer: registryMetadata.nimContainer ?? null, }); if ( - !deps.updateSandbox(sandboxName, registryFields(registryMetadata.preferredInferenceApi ?? null)) + !deps.updateSandbox( + sandboxName, + registryFields( + resolveAgentInferenceApi( + agentName, + provider, + registryMetadata.preferredInferenceApi ?? null, + ), + ), + ) ) { throw new InferenceSetError(`Failed to update NemoClaw registry for sandbox '${sandboxName}'.`); } diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index 57fdb3c9cc..cdc175e8cf 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -137,6 +137,53 @@ describe("getRebuildEndpointFromRegistry", () => { }); describe("prepareRebuildResumeConfig", () => { + it("preserves a stale Hermes API marker so rebuild re-arms provider setup (#6289)", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); + + const config = prepareRebuildResumeConfig( + "alpha", + entry({ + provider: "compatible-anthropic-endpoint", + model: "nvidia/nvidia/nemotron-3-super-v3", + endpointUrl: "https://inference-api.nvidia.com", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", + }), + "hermes", + noopLog, + throwingBail, + ); + + expect(config).toMatchObject({ + agent: "hermes", + provider: "compatible-anthropic-endpoint", + model: "nvidia/nvidia/nemotron-3-super-v3", + endpointUrl: "https://inference-api.nvidia.com", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", + }); + }); + + it("preserves legacy OpenClaw custom Anthropic routes during rebuild (#6289)", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); + + const config = prepareRebuildResumeConfig( + "alpha", + entry({ + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + endpointUrl: "https://anthropic-compatible.example/v1", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", + }), + null, + noopLog, + throwingBail, + ); + + expect(config?.preferredInferenceApi).toBe("anthropic-messages"); + }); + it("recovers a complete legacy selection only from the target's matching session", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "alpha", diff --git a/src/lib/actions/sandbox/rebuild-resume-config.ts b/src/lib/actions/sandbox/rebuild-resume-config.ts index 3f22b6f55d..6e9da90463 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.ts @@ -227,6 +227,9 @@ export function prepareRebuildResumeConfig( model: trustedSelection.model, nimContainer: trustedSelection.nimContainer, credentialEnv, + // Preserve the recorded API family through the handoff. The provider + // inference state compares it with the agent-required route and must see + // the stale value to re-arm gateway provider setup before recreation. preferredInferenceApi: trustedSelection.preferredInferenceApi, compatibleEndpointReasoning, pinEndpoint: rebuildEndpoint.known || explicitTargetEndpoint !== null, diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index e6e1b274c4..c982d59424 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -21,10 +21,31 @@ import { OLLAMA_LOCAL_CREDENTIAL_ENV, parseGatewayInference, planInferenceRouteReconcile, + resolveAgentInferenceApi, sanitizeRouteValueForDisplay, VLLM_LOCAL_CREDENTIAL_ENV, } from "./config"; +describe("resolveAgentInferenceApi", () => { + it("uses the managed OpenAI frontend for Hermes custom Anthropic routes (#6289)", () => { + expect( + resolveAgentInferenceApi("hermes", "compatible-anthropic-endpoint", "anthropic-messages"), + ).toBe("openai-completions"); + }); + + it("preserves native Anthropic routing for OpenClaw custom endpoints (#6289)", () => { + expect( + resolveAgentInferenceApi("openclaw", "compatible-anthropic-endpoint", "anthropic-messages"), + ).toBe("anthropic-messages"); + }); + + it("preserves native Anthropic routing for the first-party Hermes provider (#6289)", () => { + expect(resolveAgentInferenceApi("hermes", "anthropic-prod", "anthropic-messages")).toBe( + "anthropic-messages", + ); + }); +}); + describe("inference selection config", () => { it("exposes the curated cloud model picker options", () => { expect(CLOUD_MODEL_OPTIONS).toEqual([ @@ -405,7 +426,7 @@ describe("coerceAgentInferenceApi", () => { expect(coerceAgentInferenceApi(openclawAgent, "anthropic-messages")).toBe("anthropic-messages"); }); - it("does not touch custom-provider agents (Hermes) that speak Anthropic natively", () => { + it("leaves provider-specific Hermes routing to resolveAgentInferenceApi (#6289)", () => { const hermesAgent = { inference: { provider_type: "custom" } }; expect(coerceAgentInferenceApi(hermesAgent, "anthropic-messages")).toBe("anthropic-messages"); }); diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index f0002409c3..70e5410cd0 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -92,6 +92,23 @@ export interface SandboxInferenceConfig { inferenceCompat: Record | null; } +/** + * Resolve provider-specific managed-proxy protocol requirements for an agent. + * Hermes must use the OpenAI-compatible frontend for custom Anthropic routes + * because the managed Anthropic SSE frontend can emit duplicate message_start + * events (#6289). Provider setup then verifies the endpoint's OpenAI surface + * and aligns the OpenShell provider type before the route is used. + */ +export function resolveAgentInferenceApi( + agentName: string | null | undefined, + provider: string | null | undefined, + preferredInferenceApi: string | null, +): string | null { + return agentName === "hermes" && provider === "compatible-anthropic-endpoint" + ? "openai-completions" + : preferredInferenceApi; +} + export function getProviderSelectionConfig( provider: string, model?: string, @@ -265,46 +282,11 @@ export function getSandboxInferenceConfig( } /** - * OpenAI `/chat/completions`-only agents (manifest `provider_type: - * openai_compatible`, e.g. `langchain-deepagents-code` / dcode) cannot speak - * the Anthropic Messages API. When such an agent is onboarded against an - * Anthropic-compatible endpoint, the endpoint probe resolves the inference API - * to `anthropic-messages`, which routes getSandboxInferenceConfig() through the - * raw Anthropic branch — dropping the `/v1` suffix the OpenAI client appends to - * `/chat/completions` and wiring the sandbox for the wrong contract. The - * OpenShell sandbox L7 inference proxy only recognizes fixed `/v1` API paths, - * so the `/chat/completions` call (no `/v1`) is denied with a 403, surfaced as - * PermissionDeniedError. Route such agents through the managed - * OpenAI-compatible config instead — the same getSandboxInferenceConfig branch - * the Bedrock Runtime custom-Anthropic flow uses — so the baked base_url keeps - * its `/v1` suffix. Note this fixes the sandbox-side wiring only: the gateway - * provider for compatible-anthropic-endpoint is still registered as - * type=anthropic, whose route accepts only the anthropic_messages protocol, so - * openai_chat_completions traffic needs a gateway-side answer (translation, - * type switch, or onboarding rejection) tracked on #6294. - * - * Source-of-truth review (PRA-2 acceptance): - * - * - Invalid state worked around: an `openai_compatible` agent whose - * Anthropic-Messages endpoint probe resolves `preferredInferenceApi` to - * `anthropic-messages`, producing a baked base_url with the `/v1` suffix - * stripped while the gateway provider is registered as type=anthropic — - * the `/chat/completions` (no `/v1`) call is then denied 403. - * - Source boundary: this coercion is a NemoClaw-side band-aid on the - * sandbox-side wiring only. It does NOT change the gateway provider type - * or protocol; it only re-selects the sandbox inference API so the baked - * base_url keeps `/v1`. - * - Real fix location: gateway-side (protocol translation, a type switch, - * or an explicit onboarding rejection), tracked on #6294. This function - * is not the fix — it keeps the sandbox usable until #6294 lands. - * - Regression tests: `src/lib/inference/config.test.ts` - * (describe "coerceAgentInferenceApi") pins the coerce / no-coerce matrix, - * and `test/onboard-anthropic-compatible-openai-agent.test.ts` covers the - * end-to-end onboarding path. - * - Removal condition: delete this coercion (and revert callers to pass - * `preferredInferenceApi` straight through) once #6294 gives the gateway - * a first-class answer for openai_chat_completions on an Anthropic - * endpoint, so the probe no longer needs sandbox-side correction. + * OpenAI-only agents cannot consume an Anthropic Messages route. Select the + * managed OpenAI frontend for those agents; provider setup then probes the + * endpoint's OpenAI surface and registers the OpenShell provider as `openai` + * before routing traffic. Provider-specific overrides for multi-protocol + * agents such as Hermes are applied separately by resolveAgentInferenceApi(). */ export function coerceAgentInferenceApi( agent: unknown, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0faf51df83..637acd1698 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -259,7 +259,7 @@ const onboardProviders = require("./onboard/providers"); const inferenceProviders: typeof import("./onboard/inference-providers") = require("./onboard/inference-providers"); const setupInferenceFactory: typeof import("./onboard/setup-inference") = require("./onboard/setup-inference"); -const { ensureResumeProviderReady } = require("./onboard/resume-provider-shim"); +const resumeProviderShim = require("./onboard/resume-provider-shim"); const hermesProviderAuth = require("./hermes-provider-auth"); const onboardHermesDashboard: typeof import("./onboard/hermes-dashboard") = require("./onboard/hermes-dashboard"); const hermesAuth: typeof import("./onboard/hermes-auth") = require("./onboard/hermes-auth"); @@ -4862,7 +4862,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), skippedStepMessage, - ensureResumeProviderReady, + ...resumeProviderShim, recordStateSkipped, recordRepairEvent, hydrateCredentialEnv, diff --git a/src/lib/onboard/gateway-provider-metadata.test.ts b/src/lib/onboard/gateway-provider-metadata.test.ts index 56896e322e..e59dcd7286 100644 --- a/src/lib/onboard/gateway-provider-metadata.test.ts +++ b/src/lib/onboard/gateway-provider-metadata.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { + matchesGatewayProviderBinding, parseGatewayProviderMetadata, readGatewayProviderMetadata, } from "./gateway-provider-metadata"; @@ -19,6 +20,29 @@ const COMPLETE_OUTPUT = [ ].join("\n"); describe("gateway provider metadata", () => { + it("matches only an exact non-secret provider binding (#6289)", () => { + const metadata = parseGatewayProviderMetadata( + "Name: compatible-anthropic-endpoint\nType: openai\nCredential keys: COMPATIBLE_ANTHROPIC_API_KEY\nConfig keys: OPENAI_BASE_URL", + ); + const expected = { + name: "compatible-anthropic-endpoint", + type: "openai", + credentialKey: "COMPATIBLE_ANTHROPIC_API_KEY", + configKey: "OPENAI_BASE_URL", + }; + + expect(matchesGatewayProviderBinding(metadata, expected)).toBe(true); + expect(matchesGatewayProviderBinding({ ...metadata!, type: "anthropic" }, expected)).toBe( + false, + ); + expect( + matchesGatewayProviderBinding( + { ...metadata!, configKeys: ["OPENAI_BASE_URL", "EXTRA_FLAG"] }, + expected, + ), + ).toBe(false); + }); + it("parses one complete ANSI-decorated provider identity", () => { expect(parseGatewayProviderMetadata(COMPLETE_OUTPUT)).toEqual({ name: "compatible-endpoint", diff --git a/src/lib/onboard/gateway-provider-metadata.ts b/src/lib/onboard/gateway-provider-metadata.ts index 50c8b7841e..488952d8a9 100644 --- a/src/lib/onboard/gateway-provider-metadata.ts +++ b/src/lib/onboard/gateway-provider-metadata.ts @@ -20,6 +20,29 @@ export type GatewayProviderMetadata = { configKeys: string[]; }; +export type GatewayProviderBinding = { + name: string; + type: string; + credentialKey: string; + configKey: string; +}; + +/** Match the complete non-secret provider identity used for route decisions. */ +export function matchesGatewayProviderBinding( + metadata: GatewayProviderMetadata | null, + expected: GatewayProviderBinding, +): boolean { + return Boolean( + metadata && + metadata.name === expected.name && + metadata.type === expected.type && + metadata.credentialKeys.length === 1 && + metadata.credentialKeys[0] === expected.credentialKey && + metadata.configKeys.length === 1 && + metadata.configKeys[0] === expected.configKey, + ); +} + type GatewayProviderCommandResult = { status: number | null; stdout?: string | Buffer | null; diff --git a/src/lib/onboard/inference-providers/remote.ts b/src/lib/onboard/inference-providers/remote.ts index 590cba7682..3f1dfdd3f6 100644 --- a/src/lib/onboard/inference-providers/remote.ts +++ b/src/lib/onboard/inference-providers/remote.ts @@ -254,7 +254,7 @@ export async function setupRemoteProviderInference( `The selected agent requires an OpenAI-compatible /v1/chat/completions surface, ` + `but the endpoint did not answer it${surfaceProbe.message ? `: ${surfaceProbe.message}` : "."} ` + `Use an endpoint that also serves /v1/chat/completions, or onboard an agent that ` + - `supports the Anthropic Messages API (e.g. openclaw or hermes).`, + `uses the native Anthropic Messages route (for example, OpenClaw).`, ), ), }; diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 691c68e466..919b190321 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -107,6 +107,7 @@ function createPhases( forceInferenceSetup: false, credentialEnv: null, })), + isResumeProviderSurfaceReady: vi.fn(() => true), recordStateSkipped: vi.fn(async () => createSession()), recordRepairEvent: vi.fn(async () => createSession()), hydrateCredentialEnv: vi.fn(), diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index ce09dc30c8..133d733a21 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -47,6 +47,7 @@ function createDeps( credentialEnv: credentialEnv ?? null, }), ), + surfaceReady: vi.fn(() => true), recordSkip: vi.fn(async () => createSession()), repairEvent: vi.fn(async () => createSession()), hydrate: vi.fn(), @@ -81,6 +82,7 @@ function createDeps( toSessionUpdates: (updates: Record) => updates as SessionUpdates, skippedStepMessage: calls.skipped, ensureResumeProviderReady: calls.recoverProvider, + isResumeProviderSurfaceReady: calls.surfaceReady, recordStateSkipped: calls.recordSkip, recordRepairEvent: calls.repairEvent, hydrateCredentialEnv: calls.hydrate, @@ -201,6 +203,125 @@ describe("handleProviderInferenceState", () => { ]); }); + it("uses the managed OpenAI frontend for fresh Hermes custom Anthropic routes (#6289)", async () => { + const setupNim = vi.fn(async () => ({ + ...baseSelection, + provider: "compatible-anthropic-endpoint", + model: "nvidia/nvidia/nemotron-3-super-v3", + endpointUrl: "https://inference-api.nvidia.com", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", + })); + const { deps, calls } = createDeps({ setupNim }); + + const result = await handleProviderInferenceState({ + ...baseOptions(deps), + agent: { name: "hermes" }, + sandboxName: "hermes-custom", + }); + + expect(calls.complete).toHaveBeenCalledWith( + "provider_selection", + expect.objectContaining({ + provider: "compatible-anthropic-endpoint", + endpointUrl: "https://inference-api.nvidia.com", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "openai-completions", + }), + ); + expect(calls.setupInference).toHaveBeenCalledWith( + "hermes-custom", + "nvidia/nvidia/nemotron-3-super-v3", + "compatible-anthropic-endpoint", + "https://inference-api.nvidia.com", + "COMPATIBLE_ANTHROPIC_API_KEY", + null, + [], + { allowToolsIncompatible: false, preferredInferenceApi: "openai-completions" }, + ); + expect(result.preferredInferenceApi).toBe("openai-completions"); + }); + + it("repairs recovered Hermes custom Anthropic API metadata during rebuild (#6289)", async () => { + const session = createSession({ + agent: "hermes", + sandboxName: "hermes-custom", + provider: "compatible-anthropic-endpoint", + model: "nvidia/nvidia/nemotron-3-super-v3", + endpointUrl: "https://inference-api.nvidia.com", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", + }); + const { deps, calls } = createDeps({ isInferenceRouteReady: vi.fn(() => true) }); + + const result = await handleProviderInferenceState({ + ...baseOptions(deps, session), + resume: true, + authoritativeResumeConfig: true, + agent: { name: "hermes" }, + sandboxName: "hermes-custom", + }); + + expect(calls.setupNim).not.toHaveBeenCalled(); + expect(calls.complete).toHaveBeenCalledWith( + "provider_selection", + expect.objectContaining({ preferredInferenceApi: "anthropic-messages" }), + ); + expect(calls.setupInference).toHaveBeenCalledWith( + "hermes-custom", + "nvidia/nvidia/nemotron-3-super-v3", + "compatible-anthropic-endpoint", + "https://inference-api.nvidia.com", + "COMPATIBLE_ANTHROPIC_API_KEY", + null, + [], + { allowToolsIncompatible: false, preferredInferenceApi: "openai-completions" }, + ); + expect(calls.complete).toHaveBeenCalledWith( + "inference", + expect.objectContaining({ preferredInferenceApi: "openai-completions" }), + ); + expect(result.preferredInferenceApi).toBe("openai-completions"); + }); + + it("repairs a stale live provider even when Hermes metadata already says OpenAI (#6289)", async () => { + const session = createSession({ + agent: "hermes", + sandboxName: "hermes-custom", + provider: "compatible-anthropic-endpoint", + model: "nvidia/nvidia/nemotron-3-super-v3", + endpointUrl: "https://inference-api.nvidia.com", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "openai-completions", + }); + const { deps, calls } = createDeps({ + isInferenceRouteReady: vi.fn(() => true), + isResumeProviderSurfaceReady: vi.fn(() => false), + }); + + await handleProviderInferenceState({ + ...baseOptions(deps, session), + resume: true, + authoritativeResumeConfig: true, + agent: { name: "hermes" }, + sandboxName: "hermes-custom", + }); + + expect(calls.log).toHaveBeenCalledWith( + " [resume] Refreshing the gateway provider to match the required inference surface.", + ); + expect(calls.setupInference).toHaveBeenCalledWith( + "hermes-custom", + "nvidia/nvidia/nemotron-3-super-v3", + "compatible-anthropic-endpoint", + "https://inference-api.nvidia.com", + "COMPATIBLE_ANTHROPIC_API_KEY", + null, + [], + { allowToolsIncompatible: false, preferredInferenceApi: "openai-completions" }, + ); + }); + describe("compatible endpoint reasoning mode", () => { it("records reasoning state during provider selection", async () => { const setupNim = vi.fn(async () => ({ diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index 4cfcd0ddf3..63fe90ac96 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { coerceAgentInferenceApi } from "../../../inference/config"; +import { coerceAgentInferenceApi, resolveAgentInferenceApi } from "../../../inference/config"; import type { WebSearchConfig } from "../../../inference/web-search"; import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session"; import { withInferenceTrace, withProviderSelectionTrace } from "../../tracing"; @@ -95,6 +95,12 @@ export interface ProviderInferenceStateOptions { provider: string | null | undefined, credentialEnv: string | null | undefined, ): Promise<{ forceInferenceSetup: boolean; credentialEnv: string | null }>; + isResumeProviderSurfaceReady( + provider: string | null | undefined, + preferredInferenceApi: string | null | undefined, + credentialEnv: string | null | undefined, + endpointUrl: string | null | undefined, + ): boolean; recordStateSkipped( state: "provider_selection" | "inference", metadata?: Record | null, @@ -249,12 +255,15 @@ export async function handleProviderInferenceState({ ? constants.hermesApiKeyAuthMethod : null); let hermesToolGateways = initial.hermesToolGateways; - // A session persisted before the #6294 fix can carry anthropic-messages for - // an OpenAI-/chat/completions-only agent (provider_type: openai_compatible). - // The resume shortcut below skips setupNim — the fresh-onboard coercion - // point — so coerce the persisted seed here too, or a resume/rebuild would - // re-bake the sandbox base_url without its /v1 suffix. - let preferredInferenceApi = coerceAgentInferenceApi(agent, initial.preferredInferenceApi); + // Sessions persisted before #6294/#6289 can carry an API family that the + // selected agent cannot safely use. Normalize the seed before the resume + // shortcut so the gateway provider is revalidated and, when necessary, + // re-registered on the matching protocol surface before sandbox creation. + let preferredInferenceApi = resolveAgentInferenceApi( + agentName(agent), + provider, + coerceAgentInferenceApi(agent, initial.preferredInferenceApi), + ); let compatibleEndpointReasoning = initial.compatibleEndpointReasoning; let nimContainer = initial.nimContainer; const webSearchConfig = initial.webSearchConfig; @@ -286,14 +295,24 @@ export async function handleProviderInferenceState({ // default provider selection if the recreate fails after this point. shouldRecordProviderSelection = authoritativeResumeConfig; if (preferredInferenceApi !== initial.preferredInferenceApi) { - // #6294 heal: the pre-fix session left the gateway provider - // registered for the Anthropic Messages surface. Re-run inference - // setup so the registration is refreshed for the coerced OpenAI - // route. The coerced value is persisted only after that setup - // succeeds (below, with the inference step record) — persisting it - // here would disarm the heal permanently if the first attempt fails - // (e.g. keyless resume), stranding the sandbox on a stale route. + // #6294/#6289 heal: the pre-fix session can leave the gateway provider + // registered for a protocol that no longer matches the agent route. + // Re-run inference setup so the provider surface is revalidated and + // refreshed. Persist the adjusted value only after setup succeeds. + forceInferenceSetup = true; + } + if ( + !deps.isResumeProviderSurfaceReady( + provider, + preferredInferenceApi, + credentialEnv, + endpointUrl, + ) + ) { forceInferenceSetup = true; + deps.log( + " [resume] Refreshing the gateway provider to match the required inference surface.", + ); } const hydratedCredential = deps.hydrateCredentialEnv(credentialEnv); // A rebuild recreate may leave `openshell inference get` reporting the @@ -380,16 +399,20 @@ export async function handleProviderInferenceState({ shouldRecordProviderSelection = true; } - // #6294: persist the coerced inference API only together with a - // successful inference-step record further below — a failed heal must - // leave the stale persisted seed in place so the next resume re-arms. - const healCoercedInferenceApi = + // Persist a repaired API family only together with a successful inference + // step. A failed heal must leave the stale seed in place so resume re-arms. + const healAdjustedInferenceApi = resumeProviderSelection && preferredInferenceApi !== initial.preferredInferenceApi; const selected = requireSelection(provider, model, deps); const selectedProvider = selected.provider; const selectedModel = selected.model; provider = selectedProvider; model = selectedModel; + preferredInferenceApi = resolveAgentInferenceApi( + agentName(agent), + provider, + preferredInferenceApi, + ); if (shouldRecordProviderSelection) { session = await deps.recordStepComplete( "provider_selection", @@ -400,7 +423,12 @@ export async function handleProviderInferenceState({ credentialEnv, hermesAuthMethod, hermesToolGateways, - preferredInferenceApi, + // An authoritative rebuild records route fidelity before inference + // setup. Keep the stale marker until the provider surface heal + // succeeds so a failed attempt remains armed on the next resume. + preferredInferenceApi: healAdjustedInferenceApi + ? initial.preferredInferenceApi + : preferredInferenceApi, compatibleEndpointReasoning, nimContainer, }), @@ -595,10 +623,9 @@ export async function handleProviderInferenceState({ compatibleEndpointReasoning, nimContainer, hermesToolGateways, - // The forced #6294 heal succeeded: the gateway registration now - // matches the coerced route, so the session may safely stop carrying - // the stale anthropic-messages seed. - ...(healCoercedInferenceApi ? { preferredInferenceApi } : {}), + // The forced #6294/#6289 heal succeeded: the gateway registration now + // matches the adjusted route, so the stale session seed can be replaced. + ...(healAdjustedInferenceApi ? { preferredInferenceApi } : {}), }), ); break; diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts index fd1ddd0219..44fe4a8754 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from "vitest"; -import { decideSandboxResume, type SandboxResumeSignals } from "./sandbox-resume"; +import { + decideSandboxResume, + hasHermesCompatibleAnthropicInferenceRouteDrift, + type SandboxResumeSignals, +} from "./sandbox-resume"; function resumeSignals(overrides: Partial = {}): SandboxResumeSignals { return { @@ -11,6 +15,7 @@ function resumeSignals(overrides: Partial = {}): SandboxRe resumeAgentChanged: false, sandboxStepComplete: true, sandboxReuseState: "ready", + inferenceRouteConfigChanged: false, webSearchConfigChanged: false, sandboxGpuConfigChanged: false, messagingChannelConfigChanged: false, @@ -41,6 +46,65 @@ describe("decideSandboxResume", () => { }); }); + it("preserves registry fidelity while recreating for Hermes inference route drift", () => { + expect(decideSandboxResume(resumeSignals({ inferenceRouteConfigChanged: true }))).toEqual({ + kind: "recreate", + note: " [resume] Hermes inference route configuration changed; recreating sandbox.", + removeRegistryEntry: false, + }); + }); + + it("treats missing registry API metadata as stale after the session is repaired (#6289)", () => { + expect( + hasHermesCompatibleAnthropicInferenceRouteDrift({ + agentName: "hermes", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + preferredInferenceApi: "openai-completions", + registryEntry: { + name: "saved", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + }, + }), + ).toBe(true); + }); + + it("reuses a Hermes route only when registry metadata records the OpenAI frontend (#6289)", () => { + expect( + hasHermesCompatibleAnthropicInferenceRouteDrift({ + agentName: "hermes", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + preferredInferenceApi: "openai-completions", + registryEntry: { + name: "saved", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + preferredInferenceApi: "openai-completions", + }, + }), + ).toBe(false); + }); + + it.each([ + ["another agent", { agentName: "openclaw" }], + ["another provider", { provider: "anthropic-prod" }], + ["the native Anthropic frontend", { preferredInferenceApi: "anthropic-messages" }], + ["no selected model", { model: null }], + ])("does not report Hermes compatible-route drift for %s (#6289)", (_label, overrides) => { + expect( + hasHermesCompatibleAnthropicInferenceRouteDrift({ + agentName: "hermes", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + preferredInferenceApi: "openai-completions", + registryEntry: null, + ...overrides, + }), + ).toBe(false); + }); + it("distinguishes one-time tool-disclosure migration from user configuration drift", () => { expect( decideSandboxResume(resumeSignals({ toolDisclosureMigrationNeeded: true })), diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.ts b/src/lib/onboard/machine/handlers/sandbox-resume.ts index 9feb66d510..bf0901cf22 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.ts @@ -10,6 +10,7 @@ export interface SandboxResumeSignals { readonly resumeAgentChanged: boolean; readonly sandboxStepComplete: boolean; readonly sandboxReuseState: string; + readonly inferenceRouteConfigChanged: boolean; readonly webSearchConfigChanged: boolean; readonly sandboxGpuConfigChanged: boolean; readonly messagingChannelConfigChanged: boolean; @@ -18,6 +19,42 @@ export interface SandboxResumeSignals { readonly toolDisclosureChanged: boolean; } +interface InferenceRouteResumeInput { + readonly agentName: string | null | undefined; + readonly provider: string | null | undefined; + readonly model: string | null | undefined; + readonly preferredInferenceApi: string | null; + readonly registryEntry: SandboxEntry | null; +} + +export function hasHermesCompatibleAnthropicInferenceRouteDrift({ + agentName, + provider, + model, + preferredInferenceApi, + registryEntry, +}: InferenceRouteResumeInput): boolean { + if ( + agentName !== "hermes" || + provider !== "compatible-anthropic-endpoint" || + preferredInferenceApi !== "openai-completions" || + !model + ) { + return false; + } + + // The registry records what was baked into the existing sandbox. Do not + // fall back to the session: provider setup repairs that session before the + // sandbox decision runs, which could make a stale sandbox look migrated. + // Missing legacy metadata is therefore drift and triggers a one-time rebuild. + if (!registryEntry) return true; + return ( + registryEntry.provider !== provider || + registryEntry.model !== model || + registryEntry.preferredInferenceApi !== preferredInferenceApi + ); +} + export function resolveToolDisclosureResumeSignals( registryEntry: SandboxEntry | null, session: Session | null, @@ -61,6 +98,7 @@ export interface SandboxResumeDeps { function canReuseSandbox(signals: SandboxResumeSignals): boolean { return ( !signals.resumeAgentChanged && + !signals.inferenceRouteConfigChanged && !signals.webSearchConfigChanged && !signals.sandboxGpuConfigChanged && !signals.messagingChannelConfigChanged && @@ -92,9 +130,7 @@ function toolDisclosureResumeDecision(signals: SandboxResumeSignals): SandboxRes return null; } -export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision { - if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" }; - if (canReuseSandbox(signals)) return { kind: "reuse" }; +function compatibilityResumeDecision(signals: SandboxResumeSignals): SandboxResumeDecision | null { if (signals.resumeAgentChanged) { return { kind: "recreate", @@ -102,6 +138,23 @@ export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResum removeRegistryEntry: false, }; } + if (signals.inferenceRouteConfigChanged) { + return { + kind: "recreate", + note: " [resume] Hermes inference route configuration changed; recreating sandbox.", + // Preserve registry-only fidelity until createSandbox captures it for + // the guarded recreate path. + removeRegistryEntry: false, + }; + } + return null; +} + +export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision { + if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" }; + if (canReuseSandbox(signals)) return { kind: "reuse" }; + const compatibilityDecision = compatibilityResumeDecision(signals); + if (compatibilityDecision) return compatibilityDecision; if (signals.webSearchConfigChanged) { return { kind: "recreate", diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 80f99f4451..ef1ed80aaa 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -164,6 +164,59 @@ describe("handleSandboxState", () => { expect(result.session).toBe(skippedSession); }); + it("recreates a resumed Hermes sandbox when its compatible Anthropic frontend is stale", async () => { + const session = createSession({ + agent: "hermes", + sandboxName: "saved", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + preferredInferenceApi: "anthropic-messages", + }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: (name) => ({ + name, + agent: "hermes", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + toolDisclosure: "progressive", + }), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + agent: { name: "hermes", displayName: "Hermes" }, + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + preferredInferenceApi: "openai-completions", + }); + + expect(calls.note).toHaveBeenCalledWith( + " [resume] Hermes inference route configuration changed; recreating sandbox.", + ); + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).toHaveBeenCalledWith( + expect.anything(), + "claude-sonnet-proxy", + "compatible-anthropic-endpoint", + "openai-completions", + "saved", + null, + [], + null, + { name: "hermes", displayName: "Hermes" }, + null, + expect.anything(), + null, + [], + null, + { recreate: true, toolDisclosure: "progressive" }, + ); + }); + it("backfills absent rebuild fidelity after validated sandbox reuse", async () => { const session = createSession({ sandboxName: "saved", diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 986bd36856..c81ecf5cda 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -21,6 +21,7 @@ import { reconcileReusedSandboxMessaging, reconcileSandboxMessaging } from "./sa import { applySandboxResumeDecision, decideSandboxResume, + hasHermesCompatibleAnthropicInferenceRouteDrift, resolveToolDisclosureResumeSignals, type SandboxResumeDecision, } from "./sandbox-resume"; @@ -374,15 +375,22 @@ class SandboxStateFlow< state.webSearchConfig as unknown as SharedWebSearchConfig | null, this.options.hermesToolGateways, ); - const toolDisclosureSignals = resolveToolDisclosureResumeSignals( - state.sandboxName ? this.deps.getSandboxRegistryEntry(state.sandboxName) : null, - state.session, - ); + const registryEntry = state.sandboxName + ? this.deps.getSandboxRegistryEntry(state.sandboxName) + : null; + const toolDisclosureSignals = resolveToolDisclosureResumeSignals(registryEntry, state.session); return decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete", sandboxReuseState: this.deps.getSandboxReuseState(state.sandboxName), + inferenceRouteConfigChanged: hasHermesCompatibleAnthropicInferenceRouteDrift({ + agentName: (this.options.agent as { name?: string } | null)?.name, + provider: this.options.provider, + model: this.options.model, + preferredInferenceApi: this.options.preferredInferenceApi, + registryEntry, + }), webSearchConfigChanged: state.webSearchSupportDropped || state.webSearchConfigChanged, sandboxGpuConfigChanged: state.sandboxName ? this.deps.hasSandboxGpuDrift(state.sandboxName, this.options.sandboxGpuConfig) diff --git a/src/lib/onboard/providers.test.ts b/src/lib/onboard/providers.test.ts index e9bd590578..0e3f2b285f 100644 --- a/src/lib/onboard/providers.test.ts +++ b/src/lib/onboard/providers.test.ts @@ -12,6 +12,7 @@ const { HOSTED_INFERENCE_MODEL, NON_INTERACTIVE_PROVIDER_ALIASES, NON_INTERACTIVE_PROVIDER_KEYS, + REMOTE_PROVIDER_CONFIG, buildProviderArgs, getRequestedModelHint, getRequestedProviderHint, @@ -25,6 +26,14 @@ const { HOSTED_INFERENCE_MODEL: string; NON_INTERACTIVE_PROVIDER_ALIASES: Record; NON_INTERACTIVE_PROVIDER_KEYS: Set; + REMOTE_PROVIDER_CONFIG: Record< + string, + { + providerName: string; + providerType: string; + credentialEnv: string; + } + >; buildProviderArgs: ( action: "create" | "update", name: string, @@ -105,6 +114,27 @@ function withProviderEnv(next: Record, testBody: () } describe("onboard provider helpers", () => { + it("keeps the discovery profile Anthropic before agent-specific surface selection (#6289)", () => { + const provider = REMOTE_PROVIDER_CONFIG.anthropicCompatible; + + // Remote provider setup can replace this registration with type=openai + // after an agent selects and verifies the endpoint's OpenAI surface. + expect(provider).toMatchObject({ + providerName: "compatible-anthropic-endpoint", + providerType: "anthropic", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + }); + expect( + buildProviderArgs( + "create", + provider.providerName, + provider.providerType, + provider.credentialEnv, + "https://inference-api.nvidia.com", + ), + ).toContain("ANTHROPIC_BASE_URL=https://inference-api.nvidia.com"); + }); + it("builds create arguments for generic providers", () => { const args = buildProviderArgs( "create", diff --git a/src/lib/onboard/resume-provider-shim.ts b/src/lib/onboard/resume-provider-shim.ts index 267fdbd896..f75544c2ee 100644 --- a/src/lib/onboard/resume-provider-shim.ts +++ b/src/lib/onboard/resume-provider-shim.ts @@ -5,10 +5,16 @@ // dependencies it needs. Lives outside `src/lib/onboard.ts` so the wiring // doesn't count against the entrypoint-budget gate. +import { runOpenshell } from "../adapters/openshell/runtime"; +import { D, R } from "../cli/terminal-style"; +import { isBedrockRuntimeEndpoint } from "../inference/bedrock-runtime"; import { DEFAULT_ROUTE_CREDENTIAL_ENV } from "../inference/config"; -import { hydrateCredentialEnv } from "./credential-env"; import { validateNvidiaApiKeyValue } from "../validation"; -import { D, R } from "../cli/terminal-style"; +import { hydrateCredentialEnv } from "./credential-env"; +import { + matchesGatewayProviderBinding, + readGatewayProviderMetadata, +} from "./gateway-provider-metadata"; import { ensureResumeProviderReady as ensureResumeProviderReadyImpl, type ResumeProviderRecoveryDeps, @@ -53,3 +59,29 @@ export async function ensureResumeProviderReady( exit: (c) => process.exit(c), }); } + +export function isResumeProviderSurfaceReady( + provider: string | null | undefined, + preferredInferenceApi: string | null | undefined, + credentialEnv: string | null | undefined, + endpointUrl: string | null | undefined, +): boolean { + if ( + provider !== "compatible-anthropic-endpoint" || + preferredInferenceApi !== "openai-completions" || + isBedrockRuntimeEndpoint(endpointUrl) + ) { + return true; + } + + const metadata = readGatewayProviderMetadata( + provider, + runOpenshell as unknown as Parameters[1], + ); + return matchesGatewayProviderBinding(metadata, { + name: provider, + type: "openai", + credentialKey: credentialEnv ?? "COMPATIBLE_ANTHROPIC_API_KEY", + configKey: "OPENAI_BASE_URL", + }); +} diff --git a/test/e2e/live/hermes-inference-switch-helpers.ts b/test/e2e/live/hermes-inference-switch-helpers.ts index ddfeb65eee..7d87153497 100644 --- a/test/e2e/live/hermes-inference-switch-helpers.ts +++ b/test/e2e/live/hermes-inference-switch-helpers.ts @@ -7,6 +7,7 @@ import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; +import { resolveAgentInferenceApi } from "../../../src/lib/inference/config.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { @@ -39,14 +40,22 @@ export const SWITCH_PROVIDER = process.env.NEMOCLAW_SWITCH_PROVIDER ?? PUBLIC_NVIDIA_SWITCH_PROVIDER; export const SWITCH_MODEL = process.env.NEMOCLAW_SWITCH_MODEL ?? PUBLIC_NVIDIA_SWITCH_MODEL; export const SWITCH_API = process.env.NEMOCLAW_SWITCH_INFERENCE_API ?? "openai-completions"; +export const RUNTIME_SWITCH_API = + resolveAgentInferenceApi("hermes", SWITCH_PROVIDER, SWITCH_API) ?? SWITCH_API; const SWITCH_MOCK_PORT = Number.parseInt(process.env.NEMOCLAW_SWITCH_MOCK_PORT ?? "0", 10); const INSTALL_ATTEMPTS = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true" ? 3 : 1; -interface MockAnthropicProvider { +interface MockCompatibleAnthropicProvider { endpointUrl: string; close(): Promise; } +export function compatibleAnthropicMetadataArgs(endpointUrl: string | null): string[] { + return endpointUrl + ? ["--endpoint-url", endpointUrl, "--credential-env", "COMPATIBLE_ANTHROPIC_API_KEY"] + : []; +} + export function mockAnthropicEndpointUrl( port: number, runtimeEnv: NodeJS.ProcessEnv = process.env, @@ -55,6 +64,11 @@ export function mockAnthropicEndpointUrl( return `http://${host}:${port}`; } +export function openAiSurfaceEndpointUrl(endpointUrl: string): string { + const trimmed = endpointUrl.replace(/\/+$/u, ""); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +} + export function mockAnthropicSwitchEnabled(runtimeEnv: NodeJS.ProcessEnv = process.env): boolean { return ( (runtimeEnv.NEMOCLAW_SWITCH_PROVIDER ?? SWITCH_PROVIDER) === "compatible-anthropic-endpoint" && @@ -181,6 +195,25 @@ export async function runHermesPongWithRetry(options: { throw new Error("Hermes live probe retry loop completed without running an attempt."); } +export async function runHermesCliPongWithRetry(options: { + attempts?: number; + delay?: (milliseconds: number) => Promise; + run: (attempt: number) => Promise; +}): Promise { + const attempts = options.attempts ?? 3; + const delay = + options.delay ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + let last: ShellProbeResult | undefined; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + last = await options.run(attempt); + if ((last.exitCode === 0 && /\bPONG\b/iu.test(last.stdout)) || attempt === attempts) + return last; + await delay(5_000); + } + throw new Error("Hermes CLI retry loop completed without running an attempt."); +} + export async function cleanupHermesSwitch( host: HostCliClient, sandbox: SandboxClient, @@ -219,13 +252,19 @@ function sseResponse(res: http.ServerResponse, events: Array<[string, unknown]>) res.end(); } +function openAiSseResponse(res: http.ServerResponse, chunks: unknown[]): void { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + for (const chunk of chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`); + res.end("data: [DONE]\n\n"); +} + function closeServer(server: Server): Promise { return new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } -async function startMockAnthropicProvider(): Promise { +async function startMockAnthropicProvider(): Promise { const server = http.createServer((req, res) => { const url = new URL(req.url ?? "/", "http://mock.local"); if (req.method === "GET" && url.pathname === "/health") @@ -236,7 +275,9 @@ async function startMockAnthropicProvider(): Promise { ) { return jsonResponse(res, 200, { data: [{ id: "mock-anthropic-model" }] }); } - if (req.method !== "POST" || url.pathname !== "/v1/messages") { + const isAnthropicMessages = url.pathname === "/v1/messages"; + const isOpenAiChatCompletions = url.pathname === "/v1/chat/completions"; + if (req.method !== "POST" || (!isAnthropicMessages && !isOpenAiChatCompletions)) { return jsonResponse(res, 404, { error: "not found", path: url.pathname }); } let raw = ""; @@ -247,6 +288,43 @@ async function startMockAnthropicProvider(): Promise { req.on("end", () => { const payload = JSON.parse(raw || "{}") as { model?: unknown; stream?: unknown }; const model = typeof payload.model === "string" ? payload.model : "mock-anthropic-model"; + if (isOpenAiChatCompletions) { + if (payload.stream === true) { + return openAiSseResponse(res, [ + { + id: "chatcmpl_mock", + object: "chat.completion.chunk", + created: 0, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }, + { + id: "chatcmpl_mock", + object: "chat.completion.chunk", + created: 0, + model, + choices: [{ index: 0, delta: { content: "PONG" }, finish_reason: null }], + }, + { + id: "chatcmpl_mock", + object: "chat.completion.chunk", + created: 0, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + ]); + } + return jsonResponse(res, 200, { + id: "chatcmpl_mock", + object: "chat.completion", + created: 0, + model, + choices: [ + { index: 0, message: { role: "assistant", content: "PONG" }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + } if (payload.stream === true) { return sseResponse(res, [ [ @@ -335,16 +413,15 @@ export async function ensureCompatibleAnthropicSwitchProvider( const providerScript = [ "set -euo pipefail", "if openshell provider get -g nemoclaw compatible-anthropic-endpoint >/dev/null 2>&1; then", - ' openshell provider update -g nemoclaw compatible-anthropic-endpoint --credential COMPATIBLE_ANTHROPIC_API_KEY --config "ANTHROPIC_BASE_URL=${SWITCH_ENDPOINT_URL}"', - "else", - ' openshell provider create -g nemoclaw --name compatible-anthropic-endpoint --type anthropic --credential COMPATIBLE_ANTHROPIC_API_KEY --config "ANTHROPIC_BASE_URL=${SWITCH_ENDPOINT_URL}"', + " openshell provider delete -g nemoclaw compatible-anthropic-endpoint", "fi", + 'openshell provider create -g nemoclaw --name compatible-anthropic-endpoint --type openai --credential COMPATIBLE_ANTHROPIC_API_KEY --config "OPENAI_BASE_URL=${SWITCH_OPENAI_ENDPOINT_URL}"', ].join("\n"); const result = await host.command("bash", ["-lc", providerScript], { artifactName: "register-compatible-anthropic-switch-provider", env: env(undefined, { COMPATIBLE_ANTHROPIC_API_KEY: compatibleKey, - SWITCH_ENDPOINT_URL: endpointUrl, + SWITCH_OPENAI_ENDPOINT_URL: openAiSurfaceEndpointUrl(endpointUrl), }), redactionValues: [compatibleKey], timeoutMs: 120_000, @@ -450,12 +527,12 @@ export function maybeAssertPidStable( } export function expectedBaseUrl(): string { - return SWITCH_API === "anthropic-messages" + return RUNTIME_SWITCH_API === "anthropic-messages" ? "https://inference.local" : "https://inference.local/v1"; } -export function inferenceLocalMaxTokens(api: string = SWITCH_API): number { +export function inferenceLocalMaxTokens(api: string = RUNTIME_SWITCH_API): number { return api === "anthropic-messages" ? 32 : 100; } @@ -463,7 +540,7 @@ export function expectedApiMode(): string | undefined { return new Map([ ["anthropic-messages", "anthropic_messages"], ["openai-responses", "codex_responses"], - ]).get(SWITCH_API); + ]).get(RUNTIME_SWITCH_API); } // This live lane runs on ubuntu-latest and intentionally uses GNU grep's @@ -528,7 +605,7 @@ function quotePayload(payload: string): string { } export function inferenceLocalCommand(payload: string): string { - return SWITCH_API === "anthropic-messages" + return RUNTIME_SWITCH_API === "anthropic-messages" ? `curl -sS --max-time 90 https://inference.local/v1/messages -H 'Content-Type: application/json' -H 'anthropic-version: 2023-06-01' -d '${quotePayload(payload)}'` : `curl -sS --max-time 90 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' -d '${quotePayload(payload)}'`; } diff --git a/test/e2e/live/hermes-inference-switch.test.ts b/test/e2e/live/hermes-inference-switch.test.ts index 6d91fb6d83..394df58e22 100644 --- a/test/e2e/live/hermes-inference-switch.test.ts +++ b/test/e2e/live/hermes-inference-switch.test.ts @@ -13,6 +13,7 @@ import { apiKeyShape, chatContent, cleanupHermesSwitch, + compatibleAnthropicMetadataArgs, ensureCompatibleAnthropicSwitchProvider, env, envHash, @@ -31,7 +32,9 @@ import { mockAnthropicSwitchEnabled, parseHermesModelBlock, parseInferenceRoute, + RUNTIME_SWITCH_API, registryState, + runHermesCliPongWithRetry, runHermesInferenceSetWithRetry, runHermesPongWithRetry, SANDBOX_NAME, @@ -54,17 +57,37 @@ function canonicalEndpoint(value: unknown): string | null { return typeof value === "string" ? new URL(value).toString() : null; } +async function expectCompatibleAnthropicOpenAiProvider( + host: Parameters[0], +): Promise { + const provider = await host.command( + "openshell", + ["provider", "get", "-g", "nemoclaw", "compatible-anthropic-endpoint"], + { + artifactName: "compatible-anthropic-openai-provider-metadata", + env: env(), + timeoutMs: 30_000, + }, + ); + expect(provider.exitCode, resultText(provider)).toBe(0); + expect(resultText(provider)).toMatch(/^\s*Type:\s*openai\s*$/imu); + expect(resultText(provider)).toContain("COMPATIBLE_ANTHROPIC_API_KEY"); + expect(resultText(provider)).toContain("OPENAI_BASE_URL"); +} + test.skipIf(!shouldRunLiveE2E())( "Hermes inference set updates route/config and preserves live runtime", { timeout: TIMEOUT_MS }, async ({ artifacts, cleanup, host, sandbox, secrets }) => { await artifacts.writeJson("target.json", { id: "hermes-inference-switch", - boundary: "install.sh + Hermes sandbox + inference set + in-sandbox health/chat probes", + boundary: + "install.sh + Hermes sandbox + inference set + in-sandbox health/chat + hermes -z probes", sandboxName: SANDBOX_NAME, switchProvider: SWITCH_PROVIDER, switchModel: SWITCH_MODEL, switchApi: SWITCH_API, + runtimeSwitchApi: RUNTIME_SWITCH_API, }); cleanup.add("destroy Hermes inference switch sandbox", () => @@ -132,20 +155,12 @@ test.skipIf(!shouldRunLiveE2E())( : null; publicProvider && expect(publicProvider.exitCode, resultText(publicProvider)).toBe(0); const switchEndpointUrl = await ensureCompatibleAnthropicSwitchProvider(host, cleanup); + switchEndpointUrl && (await expectCompatibleAnthropicOpenAiProvider(host)); const pidBefore = await hermesGatewayPid(sandbox, "pid-before"); const envHashBefore = await envHash(sandbox, "env-hash-before"); - const compatibleMetadataArgs = switchEndpointUrl - ? [ - "--endpoint-url", - switchEndpointUrl, - "--credential-env", - "COMPATIBLE_ANTHROPIC_API_KEY", - "--inference-api", - SWITCH_API, - ] - : []; + const compatibleMetadataArgs = compatibleAnthropicMetadataArgs(switchEndpointUrl); const switched = await runHermesInferenceSetWithRetry( host, redactionValues, @@ -232,7 +247,7 @@ test.skipIf(!shouldRunLiveE2E())( ); expect(state.registry.sandboxes?.[SANDBOX_NAME]?.credentialEnv).toBe(durableCredentialEnv); expect(state.registry.sandboxes?.[SANDBOX_NAME]?.preferredInferenceApi).toBe( - publicSwitch ? null : SWITCH_API, + publicSwitch ? null : RUNTIME_SWITCH_API, ); expect(state.registry.sandboxes?.[SANDBOX_NAME]?.nimContainer).toBeNull(); expect(canonicalEndpoint(state.session.endpointUrl)).toBe( @@ -241,7 +256,7 @@ test.skipIf(!shouldRunLiveE2E())( expect(state.session.credentialEnv).toBe( publicSwitch ? "OPENAI_API_KEY" : durableCredentialEnv, ); - expect(state.session.preferredInferenceApi).toBe(SWITCH_API); + expect(state.session.preferredInferenceApi).toBe(RUNTIME_SWITCH_API); expect(state.session.nimContainer).toBeNull(); const inferenceLocalPayload = JSON.stringify({ @@ -289,5 +304,17 @@ test.skipIf(!shouldRunLiveE2E())( expect(chat.exitCode, resultText(chat)).toBe(0); expect(chatContent(chat.stdout)).toMatch(/PONG/i); expect(inferenceResponseModel(chat.stdout)).toBe(SWITCH_MODEL); + + const hermesCli = await runHermesCliPongWithRetry({ + run: (attempt) => + sandbox.exec(SANDBOX_NAME, ["hermes", "-z", "Reply with exactly one word: PONG"], { + artifactName: `hermes-cli-z-after-switch-${attempt}`, + env: env(), + redactionValues, + timeoutMs: 150_000, + }), + }); + expect(hermesCli.exitCode, resultText(hermesCli)).toBe(0); + expect(hermesCli.stdout).toMatch(/\bPONG\b/iu); }, ); diff --git a/test/e2e/support/hermes-inference-switch-command-shape.test.ts b/test/e2e/support/hermes-inference-switch-command-shape.test.ts index 6943bf59f7..92ad6b5098 100644 --- a/test/e2e/support/hermes-inference-switch-command-shape.test.ts +++ b/test/e2e/support/hermes-inference-switch-command-shape.test.ts @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAgentInferenceApi } from "../../../src/lib/inference/config.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import { DEFAULT_HOSTED_INFERENCE_MODEL } from "../fixtures/hosted-inference.ts"; @@ -13,11 +14,13 @@ import { API_KEY_SHAPE_PATTERN, apiKeyShapeCommand, cleanupHermesSwitch, + compatibleAnthropicMetadataArgs, hostedInstallModel, inferenceLocalMaxTokens, installHermes, mockAnthropicEndpointUrl, mockAnthropicSwitchEnabled, + openAiSurfaceEndpointUrl, openshellGatewayName, parseInferenceRoute, runHermesInferenceSetWithRetry, @@ -37,6 +40,36 @@ describe("Hermes inference switch command shape", () => { ); } + it("uses the OpenAI frontend for an Anthropic upstream in Hermes (#6289)", () => { + expect( + resolveAgentInferenceApi("hermes", "compatible-anthropic-endpoint", "anthropic-messages"), + ).toBe("openai-completions"); + }); + + it("preserves the requested frontend for other Hermes upstreams (#6289)", () => { + expect(resolveAgentInferenceApi("hermes", "nvidia-prod", "openai-completions")).toBe( + "openai-completions", + ); + }); + + it("omits the conflicting Anthropic frontend flag from Hermes switch metadata (#6289)", () => { + expect(compatibleAnthropicMetadataArgs("http://host.openshell.internal:18766")).toEqual([ + "--endpoint-url", + "http://host.openshell.internal:18766", + "--credential-env", + "COMPATIBLE_ANTHROPIC_API_KEY", + ]); + }); + + it("normalizes the verified OpenAI surface URL for Hermes custom Anthropic routes (#6289)", () => { + expect(openAiSurfaceEndpointUrl("https://inference-api.nvidia.com/")).toBe( + "https://inference-api.nvidia.com/v1", + ); + expect(openAiSurfaceEndpointUrl("https://inference-api.nvidia.com/v1")).toBe( + "https://inference-api.nvidia.com/v1", + ); + }); + it("uses direct single-line argv for the in-sandbox API-key probe", () => { const command = apiKeyShapeCommand(); diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index c712534218..6390fb988d 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -580,6 +580,28 @@ describe("agents/hermes/generate-config.ts", () => { }); }); + it("configures the managed OpenAI frontend for a custom Anthropic upstream (#6289)", () => { + const { config } = runConfigScript({ + NEMOCLAW_MODEL: "nvidia/nvidia/nemotron-3-super-v3", + NEMOCLAW_UPSTREAM_PROVIDER: "compatible-anthropic-endpoint", + NEMOCLAW_PROVIDER_KEY: "inference", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_API: "openai-completions", + }); + + expect(config.model).toEqual({ + default: "nvidia/nvidia/nemotron-3-super-v3", + provider: "custom", + base_url: "https://inference.local/v1", + api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + }); + expect(config._nemoclaw_upstream).toEqual({ + provider: "compatible-anthropic-endpoint", + model: "nvidia/nvidia/nemotron-3-super-v3", + }); + expect(config.custom_providers[0].api_mode).toBeUndefined(); + }); + it("maps OpenAI Responses routing to Hermes' codex_responses api mode", () => { const { config } = runConfigScript({ NEMOCLAW_INFERENCE_API: "openai-responses",