From f5f87071beef2cc6afac4a2a5079197cd9042a3e Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:10:07 -0700 Subject: [PATCH 1/2] fix(hermes): use OpenAI frontend for custom Anthropic Signed-off-by: Apurv Kumaria Co-authored-by: Chengjie Wang Signed-off-by: Chengjie Wang --- docs/inference/inference-options.mdx | 7 ++ docs/inference/switch-inference-providers.mdx | 16 +++- src/lib/actions/inference-route-api.test.ts | 49 ++++++++++- src/lib/actions/inference-route-api.ts | 4 +- .../actions/inference-set-hermes-run.test.ts | 53 ++++++++++-- src/lib/actions/inference-set.ts | 45 +++++++--- .../sandbox/rebuild-resume-config.test.ts | 47 +++++++++++ .../actions/sandbox/rebuild-resume-config.ts | 7 +- src/lib/inference/config.test.ts | 21 +++++ src/lib/inference/config.ts | 16 ++++ .../handlers/provider-inference.test.ts | 67 +++++++++++++++ .../machine/handlers/provider-inference.ts | 6 ++ .../machine/handlers/sandbox-resume.test.ts | 37 ++++++++- .../machine/handlers/sandbox-resume.ts | 82 ++++++++++++++++++- .../onboard/machine/handlers/sandbox.test.ts | 53 ++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 17 +++- src/lib/onboard/providers.test.ts | 28 +++++++ .../live/hermes-inference-switch-helpers.ts | 18 +++- test/e2e/live/hermes-inference-switch.test.ts | 18 ++-- ...mes-inference-switch-command-shape.test.ts | 21 +++++ test/generate-hermes-config.test.ts | 22 +++++ 21 files changed, 587 insertions(+), 47 deletions(-) diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index 4970e5f9a9..189abc77a6 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -348,6 +348,13 @@ If your local server implements the Anthropic Messages API (`/v1/messages`), cho $$nemoclaw onboard ``` + +For `compatible-anthropic-endpoint`, NemoClaw preserves the Anthropic upstream provider profile and `COMPATIBLE_ANTHROPIC_API_KEY`. +Hermes always uses the managed OpenAI Chat Completions frontend at `https://inference.local/v1` for this provider. +This avoids duplicate Anthropic SSE `message_start` events. +OpenClaw custom Anthropic routes and first-party Anthropic routes remain on the native Anthropic Messages frontend. + + 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..59d0ab1f8a 100644 --- a/docs/inference/switch-inference-providers.mdx +++ b/docs/inference/switch-inference-providers.mdx @@ -111,8 +111,20 @@ 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 preserves the Anthropic upstream provider profile and `COMPATIBLE_ANTHROPIC_API_KEY` while always selecting the managed OpenAI Chat Completions frontend at `https://inference.local/v1`. +Hermes omits `model.api_mode` for this route. +OpenClaw custom Anthropic routes and first-party Anthropic routes remain on the native Anthropic Messages frontend. + +To migrate an already affected live Hermes sandbox, reapply its recorded provider and model without `--inference-api`: + +```bash +$$nemoclaw inference set --provider compatible-anthropic-endpoint --model --sandbox +``` + +This updates the recorded API family and the running Hermes configuration. +Rebuilding the sandbox or resuming onboarding also migrates the recorded route. + #### 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..f5e6ffd2d6 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", @@ -146,9 +146,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 +162,58 @@ 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 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 () => { diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 4af3566584..2a35a5ac43 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -8,6 +8,7 @@ import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../hermes-proxy-api-key"; import { getProviderSelectionConfig, getSandboxInferenceConfig, + resolveAgentInferenceApi, type SandboxInferenceConfig, } from "../inference/config"; import { resolveContextWindowForModel } from "../inference/context-window"; @@ -632,6 +633,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, @@ -696,22 +709,34 @@ 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}'.`); } const config = deps.readSandboxConfig(sandboxName, target); - const preferredInferenceApi = + const preferredInferenceApi = resolveAgentInferenceApi( + agentName, + provider, explicitPreferredInferenceApi ?? - resolveRuntimeInferenceApi({ - agentName, - config, - currentProvider: entry.provider, - provider, - sandboxName, - session, - }); + resolveRuntimeInferenceApi({ + agentName, + config, + currentProvider: entry.provider, + provider, + sandboxName, + session, + }), + ); const effectiveRegistryMetadata: RegistryInferenceMetadata = { ...registryMetadata, preferredInferenceApi, diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index 57fdb3c9cc..ff76758427 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("repairs legacy Hermes custom Anthropic routes before rebuild (#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: "openai-completions", + }); + }); + + 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..d22d56595c 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.ts @@ -11,6 +11,7 @@ import { CLI_NAME } from "../../cli/branding"; import { RD as _RD, D, R } from "../../cli/terminal-style"; +import { resolveAgentInferenceApi } from "../../inference/config"; import { normalizeInferenceSelection } from "../../inference/selection"; import type { RegistryInferenceRoute } from "../../onboard/rebuild-route-handoff"; import * as onboardSession from "../../state/onboard-session"; @@ -227,7 +228,11 @@ export function prepareRebuildResumeConfig( model: trustedSelection.model, nimContainer: trustedSelection.nimContainer, credentialEnv, - preferredInferenceApi: trustedSelection.preferredInferenceApi, + preferredInferenceApi: resolveAgentInferenceApi( + rebuildAgent, + trustedSelection.provider, + trustedSelection.preferredInferenceApi, + ), compatibleEndpointReasoning, pinEndpoint: rebuildEndpoint.known || explicitTargetEndpoint !== null, endpointUrl, diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index 7b6a680002..004854d816 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -20,10 +20,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([ diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index e1144a773f..d46ea73265 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -92,6 +92,22 @@ export interface SandboxInferenceConfig { inferenceCompat: Record | null; } +/** + * Resolve agent-specific managed-proxy protocol requirements without changing + * the upstream provider profile. Hermes must use the OpenAI-compatible proxy + * frontend for custom Anthropic providers because the managed Anthropic SSE + * frontend can emit duplicate message_start events (#6289). + */ +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, diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index 95d2b21360..48e7d507ec 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -201,6 +201,73 @@ 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 }, + ); + 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: "openai-completions" }), + ); + expect(result.preferredInferenceApi).toBe("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 31ab2f94da..b8f180c866 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { 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"; @@ -362,6 +363,11 @@ export async function handleProviderInferenceState({ const selectedModel = selected.model; provider = selectedProvider; model = selectedModel; + preferredInferenceApi = resolveAgentInferenceApi( + agentName(agent), + provider, + preferredInferenceApi, + ); if (shouldRecordProviderSelection) { session = await deps.recordStepComplete( "provider_selection", diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts index fd1ddd0219..148d623ea4 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it } from "vitest"; -import { decideSandboxResume, type SandboxResumeSignals } from "./sandbox-resume"; +import { createSession } from "../../../state/onboard-session"; +import { + decideSandboxResume, + hasHermesCompatibleAnthropicInferenceRouteDrift, + type SandboxResumeSignals, +} from "./sandbox-resume"; function resumeSignals(overrides: Partial = {}): SandboxResumeSignals { return { @@ -11,6 +16,7 @@ function resumeSignals(overrides: Partial = {}): SandboxRe resumeAgentChanged: false, sandboxStepComplete: true, sandboxReuseState: "ready", + inferenceRouteConfigChanged: false, webSearchConfigChanged: false, sandboxGpuConfigChanged: false, messagingChannelConfigChanged: false, @@ -41,6 +47,35 @@ 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("uses matching session API metadata when a legacy registry row omits it (#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", + }, + session: createSession({ + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + preferredInferenceApi: "anthropic-messages", + }), + }), + ).toBe(true); + }); + 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..ce76c2e1c7 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.ts @@ -1,6 +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 type { Session } from "../../../state/onboard-session"; import type { SandboxEntry } from "../../../state/registry"; import { normalizeToolDisclosure, toolDisclosureOrDefault } from "../../../tool-disclosure"; @@ -10,6 +11,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 +20,64 @@ 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; + readonly session: Session | null; +} + +function inferenceFrontendsEqual( + left: ReturnType, + right: ReturnType, +): boolean { + return ( + left.providerKey === right.providerKey && + left.inferenceBaseUrl === right.inferenceBaseUrl && + left.inferenceApi === right.inferenceApi + ); +} + +export function hasHermesCompatibleAnthropicInferenceRouteDrift({ + agentName, + provider, + model, + preferredInferenceApi, + registryEntry, + session, +}: InferenceRouteResumeInput): boolean { + if ( + agentName !== "hermes" || + provider !== "compatible-anthropic-endpoint" || + preferredInferenceApi !== "openai-completions" || + !model + ) { + return false; + } + + // The registry describes what was baked into the existing sandbox. Legacy + // rows can lack the API field, so use the matching target session only for + // fields the registry does not contain. + const recordedProvider = registryEntry?.provider ?? session?.provider; + const recordedModel = registryEntry?.model ?? session?.model ?? model; + if (!recordedProvider || !recordedModel) return false; + const sessionMatchesRecordedRoute = + session?.provider === recordedProvider && session.model === recordedModel; + const recordedPreferredInferenceApi = + registryEntry?.preferredInferenceApi ?? + (sessionMatchesRecordedRoute ? (session.preferredInferenceApi ?? null) : null); + + const recordedRoute = getSandboxInferenceConfig( + recordedModel, + recordedProvider, + recordedPreferredInferenceApi, + ); + const requestedRoute = getSandboxInferenceConfig(model, provider, preferredInferenceApi); + return !inferenceFrontendsEqual(recordedRoute, requestedRoute); +} + export function resolveToolDisclosureResumeSignals( registryEntry: SandboxEntry | null, session: Session | null, @@ -61,6 +121,7 @@ export interface SandboxResumeDeps { function canReuseSandbox(signals: SandboxResumeSignals): boolean { return ( !signals.resumeAgentChanged && + !signals.inferenceRouteConfigChanged && !signals.webSearchConfigChanged && !signals.sandboxGpuConfigChanged && !signals.messagingChannelConfigChanged && @@ -92,9 +153,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 +161,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..6483db808d 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,23 @@ 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, + session: state.session, + }), 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..456ca3f9fa 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,25 @@ function withProviderEnv(next: Record, testBody: () } describe("onboard provider helpers", () => { + it("keeps custom Anthropic upstreams on the Anthropic provider profile (#6289)", () => { + const provider = REMOTE_PROVIDER_CONFIG.anthropicCompatible; + + 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/test/e2e/live/hermes-inference-switch-helpers.ts b/test/e2e/live/hermes-inference-switch-helpers.ts index ddfeb65eee..0b7cb50a14 100644 --- a/test/e2e/live/hermes-inference-switch-helpers.ts +++ b/test/e2e/live/hermes-inference-switch-helpers.ts @@ -39,6 +39,10 @@ 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 function hermesRuntimeSwitchApi(provider: string, requestedApi: string): string { + return provider === "compatible-anthropic-endpoint" ? "openai-completions" : requestedApi; +} +export const RUNTIME_SWITCH_API = hermesRuntimeSwitchApi(SWITCH_PROVIDER, 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; @@ -47,6 +51,12 @@ interface MockAnthropicProvider { 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, @@ -450,12 +460,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 +473,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 +538,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..908e8d3432 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,6 +32,7 @@ import { mockAnthropicSwitchEnabled, parseHermesModelBlock, parseInferenceRoute, + RUNTIME_SWITCH_API, registryState, runHermesInferenceSetWithRetry, runHermesPongWithRetry, @@ -65,6 +67,7 @@ test.skipIf(!shouldRunLiveE2E())( switchProvider: SWITCH_PROVIDER, switchModel: SWITCH_MODEL, switchApi: SWITCH_API, + runtimeSwitchApi: RUNTIME_SWITCH_API, }); cleanup.add("destroy Hermes inference switch sandbox", () => @@ -136,16 +139,7 @@ test.skipIf(!shouldRunLiveE2E())( 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 +226,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 +235,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({ 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..43e6c4c2f2 100644 --- a/test/e2e/support/hermes-inference-switch-command-shape.test.ts +++ b/test/e2e/support/hermes-inference-switch-command-shape.test.ts @@ -13,6 +13,8 @@ import { API_KEY_SHAPE_PATTERN, apiKeyShapeCommand, cleanupHermesSwitch, + compatibleAnthropicMetadataArgs, + hermesRuntimeSwitchApi, hostedInstallModel, inferenceLocalMaxTokens, installHermes, @@ -37,6 +39,25 @@ describe("Hermes inference switch command shape", () => { ); } + it("uses the OpenAI frontend for an Anthropic upstream in Hermes (#6289)", () => { + expect(hermesRuntimeSwitchApi("compatible-anthropic-endpoint", "anthropic-messages")).toBe( + "openai-completions", + ); + }); + + it("preserves the requested frontend for other Hermes upstreams (#6289)", () => { + expect(hermesRuntimeSwitchApi("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("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", From b200b5a9c0e43fd5f6d38b72bddc605deff26fbd Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:36:12 -0700 Subject: [PATCH 2/2] fix(hermes): address routing review feedback Signed-off-by: Apurv Kumaria --- src/lib/actions/inference-set.ts | 21 +++++------- .../machine/handlers/sandbox-resume.test.ts | 18 ++++++++++ .../live/hermes-inference-switch-helpers.ts | 7 ++-- test/e2e/live/hermes-inference-switch.test.ts | 34 +++++++++++-------- ...mes-inference-switch-command-shape.test.ts | 12 ++++--- 5 files changed, 56 insertions(+), 36 deletions(-) diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index a5188447ed..a85a08cfb5 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -780,19 +780,16 @@ async function runInferenceSetWithoutHostLock( } const config = deps.readSandboxConfig(sandboxName, target); - const preferredInferenceApi = resolveAgentInferenceApi( - agentName, - provider, + const preferredInferenceApi = explicitPreferredInferenceApi ?? - resolveRuntimeInferenceApi({ - agentName, - config, - currentProvider: entry.provider, - provider, - sandboxName, - session, - }), - ); + resolveRuntimeInferenceApi({ + agentName, + config, + currentProvider: entry.provider, + provider, + sandboxName, + session, + }); const effectiveRegistryMetadata: RegistryInferenceMetadata = { ...registryMetadata, preferredInferenceApi, diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts index 16bc27c7be..44fe4a8754 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -87,6 +87,24 @@ describe("decideSandboxResume", () => { ).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/test/e2e/live/hermes-inference-switch-helpers.ts b/test/e2e/live/hermes-inference-switch-helpers.ts index 288403418a..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,10 +40,8 @@ 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 function hermesRuntimeSwitchApi(provider: string, requestedApi: string): string { - return provider === "compatible-anthropic-endpoint" ? "openai-completions" : requestedApi; -} -export const RUNTIME_SWITCH_API = hermesRuntimeSwitchApi(SWITCH_PROVIDER, SWITCH_API); +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; diff --git a/test/e2e/live/hermes-inference-switch.test.ts b/test/e2e/live/hermes-inference-switch.test.ts index 4ea2c16a25..394df58e22 100644 --- a/test/e2e/live/hermes-inference-switch.test.ts +++ b/test/e2e/live/hermes-inference-switch.test.ts @@ -57,6 +57,24 @@ 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 }, @@ -137,21 +155,7 @@ test.skipIf(!shouldRunLiveE2E())( : null; publicProvider && expect(publicProvider.exitCode, resultText(publicProvider)).toBe(0); const switchEndpointUrl = await ensureCompatibleAnthropicSwitchProvider(host, cleanup); - if (switchEndpointUrl) { - 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"); - } + switchEndpointUrl && (await expectCompatibleAnthropicOpenAiProvider(host)); const pidBefore = await hermesGatewayPid(sandbox, "pid-before"); const envHashBefore = await envHash(sandbox, "env-hash-before"); 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 1a878d5dca..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"; @@ -14,7 +15,6 @@ import { apiKeyShapeCommand, cleanupHermesSwitch, compatibleAnthropicMetadataArgs, - hermesRuntimeSwitchApi, hostedInstallModel, inferenceLocalMaxTokens, installHermes, @@ -41,13 +41,15 @@ describe("Hermes inference switch command shape", () => { } it("uses the OpenAI frontend for an Anthropic upstream in Hermes (#6289)", () => { - expect(hermesRuntimeSwitchApi("compatible-anthropic-endpoint", "anthropic-messages")).toBe( - "openai-completions", - ); + expect( + resolveAgentInferenceApi("hermes", "compatible-anthropic-endpoint", "anthropic-messages"), + ).toBe("openai-completions"); }); it("preserves the requested frontend for other Hermes upstreams (#6289)", () => { - expect(hermesRuntimeSwitchApi("nvidia-prod", "openai-completions")).toBe("openai-completions"); + expect(resolveAgentInferenceApi("hermes", "nvidia-prod", "openai-completions")).toBe( + "openai-completions", + ); }); it("omits the conflicting Anthropic frontend flag from Hermes switch metadata (#6289)", () => {