Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions docs/inference/switch-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</AgentOnly>

Expand Down
190 changes: 144 additions & 46 deletions src/lib/actions/inference-set-compatible-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -248,7 +248,6 @@ describe("runInferenceSet compatible providers", () => {
{
provider: "compatible-endpoint",
model: "mock-model",
noVerify: true,
endpointUrl,
credentialEnv: "COMPATIBLE_API_KEY",
inferenceApi: "openai-completions",
Expand All @@ -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",
Expand Down Expand Up @@ -298,44 +298,170 @@ 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: {
name: "alpha",
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}`),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.9/v1",
endpointUrl: "http://198.51.100.10/v1",
credentialEnv: "COMPATIBLE_API_KEY",
preferredInferenceApi: "openai-completions",
}),
Expand All @@ -348,49 +474,20 @@ describe("runInferenceSet compatible providers", () => {
{
provider: "compatible-endpoint",
model: "new-model",
noVerify: true,
endpointUrl: "http://compatible.example/v1",
credentialEnv: "COMPATIBLE_API_KEY",
inferenceApi: "openai-completions",
},
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 () => {
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/lib/actions/inference-set-degraded-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 3 additions & 3 deletions src/lib/actions/inference-set-provider-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 17 additions & 3 deletions src/lib/actions/inference-set.test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,23 @@ export function baseSession(overrides: Partial<Session> = {}): 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<typeof vi.fn> {
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",
Expand All @@ -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: "" };
}
Expand Down
Loading
Loading