From e8a9f8b6579b3fcf1a6705e74d222efffc64d557 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 28 Jul 2026 13:59:41 -0700 Subject: [PATCH 1/3] fix(inference): reject unsafe provider replacement Signed-off-by: Prekshi Vyas --- docs/inference/switch-providers.mdx | 13 +- .../inference-set-compatible-provider.test.ts | 177 +++++++++++++----- .../inference-set-degraded-state.test.ts | 5 +- .../inference-set-provider-alias.test.ts | 6 +- src/lib/actions/inference-set.test-support.ts | 20 +- src/lib/actions/inference-set.ts | 34 ++++ 6 files changed, 200 insertions(+), 55 deletions(-) diff --git a/docs/inference/switch-providers.mdx b/docs/inference/switch-providers.mdx index 439fa6b41c8..5652611ba24 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 point a direct compatible provider at a different custom endpoint, re-run onboarding with the new endpoint. +`inference set` refuses to replace an existing direct binding because OpenShell does not expose the previous endpoint 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..8e09bc429d6 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,24 +298,91 @@ 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": { + 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: 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: "" }; } - 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("rejects endpoint replacement for an existing direct compatible provider (#7725)", async () => { + const captureOpenshell = vi.fn((args: string[]) => { + switch (`${args[0]}:${args[1]}`) { + case "provider:get": { + const output = [ + "Name: compatible-endpoint", + "Id: 11111111-2222-4333-8444-555555555555", + "Type: openai", + "Resource version: 4", + "Credential keys: COMPATIBLE_API_KEY", + "Config keys: OPENAI_BASE_URL", + ].join("\n"); + return { status: 0, output, stdout: output, stderr: "" }; + } default: return { status: 0, output: "", stdout: "", stderr: "" }; } @@ -344,11 +411,64 @@ describe("runInferenceSet compatible providers", () => { 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(/Cannot replace existing provider.*Re-run onboarding/); + 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", + }, + session: baseSession({ + provider: "compatible-endpoint", + model: "old-model", + endpointUrl: "http://198.51.100.10/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + }), + captureOpenshell, + rewriteConfigUrlsWithDnsPinning: async () => "http://198.51.100.10/v1", + resolveCredentialValue: () => "replacement-upstream-secret", + }); + await runInferenceSet( { provider: "compatible-endpoint", model: "new-model", - noVerify: true, endpointUrl: "http://compatible.example/v1", credentialEnv: "COMPATIBLE_API_KEY", inferenceApi: "openai-completions", @@ -356,41 +476,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 +583,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..3eece3bbfe9 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,18 @@ function openshellInferenceSetArgs(options: { return args; } +function matchesRecordedDirectProviderBinding(options: { + entry: SandboxEntry; + provider: string; + binding: InferenceSetProviderBinding; +}): boolean { + return ( + options.entry.provider === options.provider && + options.entry.endpointUrl === options.binding.baseUrl && + options.entry.credentialEnv === options.binding.credentialEnv + ); +} + function getPreferredInferenceApi(config: ConfigObject): string | null { const models = config.models; if (!isConfigObject(models)) return null; @@ -964,6 +977,27 @@ async function runInferenceSetWithoutHostLock( binding: providerBinding, captureOpenshell: deps.captureOpenshell, }); + if (directProviderBinding && providerMutation.action === "update") { + if ( + !matchesRecordedDirectProviderBinding({ + entry, + provider, + binding: directProviderBinding, + }) + ) { + throw new InferenceSetError( + `Cannot replace existing provider '${provider}' with the requested endpoint because OpenShell does not expose the previous endpoint required for rollback. ` + + `Re-run onboarding with the new endpoint. If this sandbox already uses '${provider}', omit --endpoint-url 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( From a86b36f559440c83f7977dd46a86a1fa09bbebad Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 28 Jul 2026 14:09:57 -0700 Subject: [PATCH 2/3] test(inference): keep provider rollback regression linear Signed-off-by: Prekshi Vyas --- .../inference-set-compatible-provider.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/actions/inference-set-compatible-provider.test.ts b/src/lib/actions/inference-set-compatible-provider.test.ts index 8e09bc429d6..be5afdb4a6c 100644 --- a/src/lib/actions/inference-set-compatible-provider.test.ts +++ b/src/lib/actions/inference-set-compatible-provider.test.ts @@ -303,12 +303,9 @@ describe("runInferenceSet compatible providers", () => { const captureOpenshell = 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 = [ + 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", @@ -316,7 +313,9 @@ describe("runInferenceSet compatible providers", () => { "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:create": providerPresent = true; From 97686155bdf5a4b5cd908b212c105725672afe4d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 28 Jul 2026 14:18:39 -0700 Subject: [PATCH 3/3] fix(inference): explain direct binding mismatches Signed-off-by: Prekshi Vyas --- docs/inference/switch-providers.mdx | 4 +- .../inference-set-compatible-provider.test.ts | 50 +++++++++++-------- src/lib/actions/inference-set.ts | 32 ++++++------ 3 files changed, 47 insertions(+), 39 deletions(-) diff --git a/docs/inference/switch-providers.mdx b/docs/inference/switch-providers.mdx index 5652611ba24..6b498f2a177 100644 --- a/docs/inference/switch-providers.mdx +++ b/docs/inference/switch-providers.mdx @@ -108,8 +108,8 @@ An explicit different API family is rejected for that route. 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 point a direct compatible provider at a different custom endpoint, re-run onboarding with the new endpoint. -`inference set` refuses to replace an existing direct binding because OpenShell does not expose the previous endpoint required for rollback. +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. DNS-backed HTTPS routes use an HTTPS Pin Runtime binding. diff --git a/src/lib/actions/inference-set-compatible-provider.test.ts b/src/lib/actions/inference-set-compatible-provider.test.ts index be5afdb4a6c..803e6d6487a 100644 --- a/src/lib/actions/inference-set-compatible-provider.test.ts +++ b/src/lib/actions/inference-set-compatible-provider.test.ts @@ -368,23 +368,27 @@ describe("runInferenceSet compatible providers", () => { expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); }); - it("rejects endpoint replacement for an existing direct compatible provider (#7725)", async () => { - const captureOpenshell = vi.fn((args: string[]) => { - switch (`${args[0]}:${args[1]}`) { - case "provider:get": { - const output = [ - "Name: compatible-endpoint", - "Id: 11111111-2222-4333-8444-555555555555", - "Type: openai", - "Resource version: 4", - "Credential keys: COMPATIBLE_API_KEY", - "Config keys: OPENAI_BASE_URL", - ].join("\n"); - return { status: 0, output, stdout: output, stderr: "" }; - } - default: - return { status: 0, output: "", stdout: "", stderr: "" }; - } + 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" } } } }, @@ -393,16 +397,16 @@ 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: "COMPATIBLE_API_KEY", + credentialEnv: recordedCredentialEnv, preferredInferenceApi: "openai-completions", }, session: baseSession({ provider: "compatible-endpoint", model: "old-model", - endpointUrl: "http://198.51.100.9/v1", - credentialEnv: "COMPATIBLE_API_KEY", + endpointUrl: recordedEndpointUrl, + credentialEnv: recordedCredentialEnv, preferredInferenceApi: "openai-completions", }), captureOpenshell, @@ -421,7 +425,9 @@ describe("runInferenceSet compatible providers", () => { }, deps, ), - ).rejects.toThrow(/Cannot replace existing provider.*Re-run onboarding/); + ).rejects.toThrow( + new RegExp(`Cannot replace existing provider.*binding differs in: ${bindingPart}`), + ); expect( captureOpenshell.mock.calls.some( ([args]) => diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 3eece3bbfe9..5c56e0906d5 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -641,16 +641,18 @@ function openshellInferenceSetArgs(options: { return args; } -function matchesRecordedDirectProviderBinding(options: { +function recordedDirectProviderBindingMismatches(options: { entry: SandboxEntry; provider: string; binding: InferenceSetProviderBinding; -}): boolean { - return ( - options.entry.provider === options.provider && - options.entry.endpointUrl === options.binding.baseUrl && +}): 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 { @@ -978,16 +980,16 @@ async function runInferenceSetWithoutHostLock( captureOpenshell: deps.captureOpenshell, }); if (directProviderBinding && providerMutation.action === "update") { - if ( - !matchesRecordedDirectProviderBinding({ - entry, - provider, - binding: directProviderBinding, - }) - ) { + const bindingMismatches = recordedDirectProviderBindingMismatches({ + entry, + provider, + binding: directProviderBinding, + }); + if (bindingMismatches.length > 0) { throw new InferenceSetError( - `Cannot replace existing provider '${provider}' with the requested endpoint because OpenShell does not expose the previous endpoint required for rollback. ` + - `Re-run onboarding with the new endpoint. If this sandbox already uses '${provider}', omit --endpoint-url to switch only the model.`, + `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, ); }