diff --git a/docs/inference/switch-providers.mdx b/docs/inference/switch-providers.mdx index 439fa6b41c8..6b498f2a177 100644 --- a/docs/inference/switch-providers.mdx +++ b/docs/inference/switch-providers.mdx @@ -106,13 +106,16 @@ Supported API-family values are `openai-completions`, `anthropic-messages`, and For a Hermes `compatible-anthropic-endpoint` target, omit `--inference-api` because NemoClaw selects `openai-completions`. An explicit different API family is rejected for that route. -To point a sandbox at a different custom endpoint, re-run onboarding with the new endpoint. +To switch only the model for an existing compatible provider, omit the endpoint options. +NemoClaw reuses the endpoint in the sandbox registry and verifies the selected route. +To change a direct compatible provider's custom endpoint or credential binding, re-run onboarding with the requested binding. +`inference set` refuses to replace an existing direct binding because OpenShell does not expose the previous provider configuration required for rollback. A rebuild reuses the recorded endpoint and cannot change it. -If updating an existing compatible provider fails after OpenShell selects the new route, NemoClaw attempts to restore the previously recorded provider and model. -The command still exits nonzero because the provider binding might be partially updated. -Retry the switch or re-run onboarding to reconcile the provider. -If NemoClaw reports that it could not restore the previous selection, do not use the route until you re-run onboarding. +DNS-backed HTTPS routes use an HTTPS Pin Runtime binding. +If that binding update fails after OpenShell selects the new route, NemoClaw attempts to restore the previously recorded provider and model. +The command still exits nonzero because the binding might be partially updated. +If NemoClaw cannot restore the previous selection, do not use the route until you re-run onboarding. diff --git a/src/lib/actions/inference-set-compatible-provider.test.ts b/src/lib/actions/inference-set-compatible-provider.test.ts index 549e84f4065..803e6d6487a 100644 --- a/src/lib/actions/inference-set-compatible-provider.test.ts +++ b/src/lib/actions/inference-set-compatible-provider.test.ts @@ -7,8 +7,8 @@ import type { ConfigObject } from "../security/credential-filter"; import { runInferenceSet } from "./inference-set"; import { baseSession, + createCompatibleProviderCapture, createDeps, - createExistingCompatibleProviderCapture, } from "./inference-set.test-support"; describe("runInferenceSet compatible providers", () => { @@ -248,7 +248,6 @@ describe("runInferenceSet compatible providers", () => { { provider: "compatible-endpoint", model: "mock-model", - noVerify: true, endpointUrl, credentialEnv: "COMPATIBLE_API_KEY", inferenceApi: "openai-completions", @@ -270,6 +269,7 @@ describe("runInferenceSet compatible providers", () => { ); expect(providerCreateIndex).toBeGreaterThanOrEqual(0); expect(successfulSetIndex).toBeGreaterThan(providerCreateIndex); + expect(captureOpenshell.mock.calls[successfulSetIndex][0]).not.toContain("--no-verify"); expect(captureOpenshell.mock.calls[providerCreateIndex]).toEqual([ [ "provider", @@ -298,28 +298,98 @@ describe("runInferenceSet compatible providers", () => { ]); }); - it("updates an existing direct compatible provider when its endpoint changes (#7725)", async () => { - let providerVersion = 4; + it("removes an absent direct provider when verified route selection fails (#7725)", async () => { + let providerPresent = false; const captureOpenshell = vi.fn((args: string[]) => { switch (`${args[0]}:${args[1]}`) { case "provider:get": { - const output = [ + const missingOutput = + "Error: code: 'Some requested entity was not found', message: \"provider not found\""; + const presentOutput = [ "Name: compatible-endpoint", "Id: 11111111-2222-4333-8444-555555555555", "Type: openai", - `Resource version: ${providerVersion}`, + "Resource version: 1", "Credential keys: COMPATIBLE_API_KEY", "Config keys: OPENAI_BASE_URL", ].join("\n"); - return { status: 0, output, stdout: output, stderr: "" }; + return providerPresent + ? { status: 0, output: presentOutput, stdout: presentOutput, stderr: "" } + : { status: 1, output: missingOutput, stdout: "", stderr: missingOutput }; } - case "provider:update": - providerVersion += 1; + case "provider:create": + providerPresent = true; + return { status: 0, output: "", stdout: "", stderr: "" }; + case "provider:delete": + providerPresent = false; return { status: 0, output: "", stdout: "", stderr: "" }; + case "inference:set": + return { + status: 1, + output: "requested endpoint is unreachable", + stdout: "", + stderr: "requested endpoint is unreachable", + }; default: return { status: 0, output: "", stdout: "", stderr: "" }; } }); + const deps = createDeps({ + config: { agents: { defaults: { model: { primary: "inference/nvidia/model-a" } } } }, + entry: { + name: "alpha", + agent: "openclaw", + provider: "nvidia-prod", + model: "nvidia/model-a", + }, + session: baseSession({ + provider: "nvidia-prod", + model: "nvidia/model-a", + }), + captureOpenshell, + rewriteConfigUrlsWithDnsPinning: async () => "http://198.51.100.10/v1", + resolveCredentialValue: () => "real-upstream-secret", + }); + + await expect( + runInferenceSet( + { + provider: "compatible-endpoint", + model: "mock-model", + endpointUrl: "http://compatible.example/v1", + credentialEnv: "COMPATIBLE_API_KEY", + inferenceApi: "openai-completions", + }, + deps, + ), + ).rejects.toThrow(/newly created OpenShell provider was removed/); + expect(providerPresent).toBe(false); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); + expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); + }); + + it.each([ + { + bindingPart: "endpoint URL", + recordedEndpointUrl: "http://198.51.100.9/v1", + recordedCredentialEnv: "COMPATIBLE_API_KEY", + }, + { + bindingPart: "credential environment variable", + recordedEndpointUrl: "http://198.51.100.10/v1", + recordedCredentialEnv: "LEGACY_COMPATIBLE_API_KEY", + }, + ])("rejects $bindingPart replacement for an existing direct provider (#7725)", async ({ + bindingPart, + recordedEndpointUrl, + recordedCredentialEnv, + }) => { + const captureOpenshell = createCompatibleProviderCapture({ + name: "compatible-endpoint", + type: "openai", + credentialEnv: "COMPATIBLE_API_KEY", + configKey: "OPENAI_BASE_URL", + }); const deps = createDeps({ config: { agents: { defaults: { model: { primary: "inference/old-model" } } } }, entry: { @@ -327,7 +397,63 @@ describe("runInferenceSet compatible providers", () => { agent: "openclaw", provider: "compatible-endpoint", model: "old-model", - endpointUrl: "http://198.51.100.9/v1", + endpointUrl: recordedEndpointUrl, + endpointSource: "inference-set", + credentialEnv: recordedCredentialEnv, + preferredInferenceApi: "openai-completions", + }, + session: baseSession({ + provider: "compatible-endpoint", + model: "old-model", + endpointUrl: recordedEndpointUrl, + credentialEnv: recordedCredentialEnv, + preferredInferenceApi: "openai-completions", + }), + captureOpenshell, + rewriteConfigUrlsWithDnsPinning: async () => "http://198.51.100.10/v1", + resolveCredentialValue: () => "replacement-upstream-secret", + }); + + await expect( + runInferenceSet( + { + provider: "compatible-endpoint", + model: "new-model", + endpointUrl: "http://compatible.example/v1", + credentialEnv: "COMPATIBLE_API_KEY", + inferenceApi: "openai-completions", + }, + deps, + ), + ).rejects.toThrow( + new RegExp(`Cannot replace existing provider.*binding differs in: ${bindingPart}`), + ); + expect( + captureOpenshell.mock.calls.some( + ([args]) => + (args[0] === "inference" && args[1] === "set") || + (args[0] === "provider" && args[1] === "update"), + ), + ).toBe(false); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); + expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); + }); + + it("reuses an existing direct provider when its recorded endpoint matches", async () => { + const captureOpenshell = createCompatibleProviderCapture({ + name: "compatible-endpoint", + type: "openai", + credentialEnv: "COMPATIBLE_API_KEY", + configKey: "OPENAI_BASE_URL", + }); + const deps = createDeps({ + config: { agents: { defaults: { model: { primary: "inference/old-model" } } } }, + entry: { + name: "alpha", + agent: "openclaw", + provider: "compatible-endpoint", + model: "old-model", + endpointUrl: "http://198.51.100.10/v1", endpointSource: "inference-set", credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", @@ -335,7 +461,7 @@ describe("runInferenceSet compatible providers", () => { session: baseSession({ provider: "compatible-endpoint", model: "old-model", - endpointUrl: "http://198.51.100.9/v1", + endpointUrl: "http://198.51.100.10/v1", credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", }), @@ -348,7 +474,6 @@ describe("runInferenceSet compatible providers", () => { { provider: "compatible-endpoint", model: "new-model", - noVerify: true, endpointUrl: "http://compatible.example/v1", credentialEnv: "COMPATIBLE_API_KEY", inferenceApi: "openai-completions", @@ -356,41 +481,13 @@ describe("runInferenceSet compatible providers", () => { deps, ); - const providerGetIndex = captureOpenshell.mock.calls.findIndex( - ([args]) => args[0] === "provider" && args[1] === "get", - ); - const inferenceSetIndex = captureOpenshell.mock.calls.findIndex( + const inferenceSetCall = captureOpenshell.mock.calls.find( ([args]) => args[0] === "inference" && args[1] === "set", ); - const providerUpdateIndex = captureOpenshell.mock.calls.findIndex( - ([args]) => args[0] === "provider" && args[1] === "update", - ); - expect(providerGetIndex).toBeLessThan(inferenceSetIndex); - expect(inferenceSetIndex).toBeLessThan(providerUpdateIndex); - expect(captureOpenshell.mock.calls[providerUpdateIndex]).toEqual([ - [ - "provider", - "update", - "-g", - "nemoclaw", - "compatible-endpoint", - "--credential", - "COMPATIBLE_API_KEY", - "--config", - "OPENAI_BASE_URL=http://198.51.100.10/v1", - ], - expect.objectContaining({ - env: { COMPATIBLE_API_KEY: "replacement-upstream-secret" }, - }), - ]); - expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([ - "alpha", - expect.objectContaining({ - provider: "compatible-endpoint", - model: "new-model", - endpointUrl: "http://198.51.100.10/v1", - }), - ]); + expect(inferenceSetCall?.[0]).not.toContain("--no-verify"); + expect( + captureOpenshell.mock.calls.some(([args]) => args[0] === "provider" && args[1] === "update"), + ).toBe(false); }); it("preserves explicit inference API through the final registry and session sync", async () => { @@ -491,11 +588,12 @@ describe("runInferenceSet compatible providers", () => { agents: { defaults: { model: { primary: "inference/nvidia/model-a" } } }, models: { providers: { inference: { api: "openai-completions", models: [] } } }, }; - const captureOpenshell = createExistingCompatibleProviderCapture({ + const captureOpenshell = createCompatibleProviderCapture({ name: "compatible-anthropic-endpoint", type: "anthropic", credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", configKey: "ANTHROPIC_BASE_URL", + initiallyPresent: false, }); const deps = createDeps({ config, diff --git a/src/lib/actions/inference-set-degraded-state.test.ts b/src/lib/actions/inference-set-degraded-state.test.ts index 23d41556abd..78968d09c60 100644 --- a/src/lib/actions/inference-set-degraded-state.test.ts +++ b/src/lib/actions/inference-set-degraded-state.test.ts @@ -7,8 +7,8 @@ import type { ConfigObject } from "../security/credential-filter"; import { InferenceSetError, runInferenceSet } from "./inference-set"; import { baseSession, + createCompatibleProviderCapture, createDeps, - createExistingCompatibleProviderCapture, } from "./inference-set.test-support"; describe("runInferenceSet degraded state handling", () => { @@ -134,11 +134,12 @@ describe("runInferenceSet degraded state handling", () => { provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", }), - captureOpenshell: createExistingCompatibleProviderCapture({ + captureOpenshell: createCompatibleProviderCapture({ name: "compatible-endpoint", type: "openai", credentialEnv: "COMPATIBLE_API_KEY", configKey: "OPENAI_BASE_URL", + initiallyPresent: false, }), }); deps.calls.readSandboxConfig.mockImplementation(() => structuredClone(persistedConfig)); diff --git a/src/lib/actions/inference-set-provider-alias.test.ts b/src/lib/actions/inference-set-provider-alias.test.ts index 2285aa589a1..119b158ee85 100644 --- a/src/lib/actions/inference-set-provider-alias.test.ts +++ b/src/lib/actions/inference-set-provider-alias.test.ts @@ -26,8 +26,8 @@ import { } from "./inference-set"; import { baseSession, + createCompatibleProviderCapture, createDeps, - createExistingCompatibleProviderCapture, } from "./inference-set.test-support"; import type { EnsureHttpsPinRuntimeAdapterOptions } from "./inference-set-route-containment"; @@ -352,7 +352,7 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => { // onboarding. DNS re-resolution is not required for that exact identity. const guard = ssrfGuard(); const adapterGuard = httpsPinAdapterGuard(); - const captureOpenshell = createExistingCompatibleProviderCapture({ + const captureOpenshell = createCompatibleProviderCapture({ name: "compatible-anthropic-endpoint", type: "anthropic", credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", @@ -396,7 +396,7 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => { it("accepts the same onboard-provenanced internal endpoint after canonicalization (#6321)", async () => { const guard = ssrfGuard(); const adapterGuard = httpsPinAdapterGuard(); - const captureOpenshell = createExistingCompatibleProviderCapture({ + const captureOpenshell = createCompatibleProviderCapture({ name: "compatible-endpoint", type: "openai", credentialEnv: "COMPATIBLE_API_KEY", diff --git a/src/lib/actions/inference-set.test-support.ts b/src/lib/actions/inference-set.test-support.ts index b5b73c112af..ee842492749 100644 --- a/src/lib/actions/inference-set.test-support.ts +++ b/src/lib/actions/inference-set.test-support.ts @@ -71,16 +71,23 @@ export function baseSession(overrides: Partial = {}): Session { } as Session; } -export function createExistingCompatibleProviderCapture(options: { +export function createCompatibleProviderCapture(options: { name: string; type: "openai" | "anthropic"; credentialEnv: string; configKey: "OPENAI_BASE_URL" | "ANTHROPIC_BASE_URL"; -}): InferenceSetDeps["captureOpenshell"] { - let providerVersion = 1; + initiallyPresent?: boolean; +}): InferenceSetDeps["captureOpenshell"] & ReturnType { + let providerPresent = options.initiallyPresent ?? true; + let providerVersion = providerPresent ? 1 : 0; return vi.fn((args: string[]) => { switch (`${args[0]}:${args[1]}`) { case "provider:get": { + if (!providerPresent) { + const output = + "Error: code: 'Some requested entity was not found', message: \"provider not found\""; + return { status: 1, output, stdout: "", stderr: output }; + } const output = [ `Name: ${options.name}`, "Id: 11111111-2222-4333-8444-555555555555", @@ -91,9 +98,16 @@ export function createExistingCompatibleProviderCapture(options: { ].join("\n"); return { status: 0, output, stdout: output, stderr: "" }; } + case "provider:create": + providerPresent = true; + providerVersion = 1; + return { status: 0, output: "", stdout: "", stderr: "" }; case "provider:update": providerVersion += 1; return { status: 0, output: "", stdout: "", stderr: "" }; + case "provider:delete": + providerPresent = false; + return { status: 0, output: "", stdout: "", stderr: "" }; default: return { status: 0, output: "", stdout: "", stderr: "" }; } diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index c3e5684e78f..5c56e0906d5 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -79,6 +79,7 @@ import { import { type EnsureHttpsPinRuntimeAdapterFn, finalizeInferenceSetRoute, + type InferenceSetProviderBinding, prepareInferenceSetRoute, type RegistryInferenceMetadata, } from "./inference-set-route-containment"; @@ -640,6 +641,20 @@ function openshellInferenceSetArgs(options: { return args; } +function recordedDirectProviderBindingMismatches(options: { + entry: SandboxEntry; + provider: string; + binding: InferenceSetProviderBinding; +}): string[] { + return [ + options.entry.provider === options.provider ? null : "provider", + options.entry.endpointUrl === options.binding.baseUrl ? null : "endpoint URL", + options.entry.credentialEnv === options.binding.credentialEnv + ? null + : "credential environment variable", + ].filter((field): field is string => field !== null); +} + function getPreferredInferenceApi(config: ConfigObject): string | null { const models = config.models; if (!isConfigObject(models)) return null; @@ -964,6 +979,27 @@ async function runInferenceSetWithoutHostLock( binding: providerBinding, captureOpenshell: deps.captureOpenshell, }); + if (directProviderBinding && providerMutation.action === "update") { + const bindingMismatches = recordedDirectProviderBindingMismatches({ + entry, + provider, + binding: directProviderBinding, + }); + if (bindingMismatches.length > 0) { + throw new InferenceSetError( + `Cannot replace existing provider '${provider}' because the requested binding differs in: ${bindingMismatches.join(", ")}. ` + + `OpenShell does not expose the previous provider configuration required for rollback. ` + + `Re-run onboarding with the requested binding. If this sandbox already uses '${provider}', omit the endpoint options to switch only the model.`, + 2, + ); + } + // OpenShell redacts provider configuration values. The matching + // registry route is the only durable evidence that this request does + // not replace the provider binding. + providerMutation = null; + } + } + if (providerMutation) { appliedProvider = providerMutation.action === "create"; if (providerMutation.action === "update" && (!previousProvider || !previousModel)) { throw new InferenceSetError(