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",