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
163 changes: 139 additions & 24 deletions src/lib/actions/inference-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function createDeps(options: {
writeSandboxConfig: ReturnType<typeof vi.fn>;
recomputeSandboxConfigHash: ReturnType<typeof vi.fn>;
updateSandbox: ReturnType<typeof vi.fn>;
readSandboxConfig: ReturnType<typeof vi.fn>;
updateSession: ReturnType<typeof vi.fn>;
appendAuditEntry: ReturnType<typeof vi.fn>;
log: ReturnType<typeof vi.fn>;
Expand All @@ -145,6 +146,7 @@ function createDeps(options: {
writeSandboxConfig: vi.fn(),
recomputeSandboxConfigHash: vi.fn(),
updateSandbox: vi.fn(() => true),
readSandboxConfig: vi.fn(() => options.config),
updateSession: vi.fn((mutator: (value: Session) => Session | void) => {
const current = session ?? baseSession();
session = mutator(current) ?? current;
Expand All @@ -167,7 +169,7 @@ function createDeps(options: {
loadSession: () => session,
updateSession: calls.updateSession,
resolveAgentConfig: () => options.target ?? OPENCLAW_TARGET,
readSandboxConfig: () => options.config,
readSandboxConfig: calls.readSandboxConfig,
writeSandboxConfig: calls.writeSandboxConfig,
recomputeSandboxConfigHash: calls.recomputeSandboxConfigHash,
runOpenshell: calls.runOpenshell,
Expand Down Expand Up @@ -453,10 +455,24 @@ describe("runInferenceSet", () => {
});
expect(deps.calls.writeSandboxConfig).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET, config);
expect(deps.calls.recomputeSandboxConfigHash).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET);
expect(deps.calls.updateSandbox).toHaveBeenCalledWith("alpha", {
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
});
expect(deps.calls.updateSandbox).toHaveBeenCalledWith(
"alpha",
expect.objectContaining({
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
}),
);
expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([
"alpha",
expect.objectContaining({
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
credentialEnv: null,
endpointUrl: null,
nimContainer: null,
preferredInferenceApi: null,
}),
]);
expect(deps.getSession()).toMatchObject({
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
Expand Down Expand Up @@ -545,10 +561,13 @@ describe("runInferenceSet", () => {
"/sandbox/.hermes/config.yaml",
);
expect(deps.calls.recomputeSandboxConfigHash).toHaveBeenCalledWith("hermes", HERMES_TARGET);
expect(deps.calls.updateSandbox).toHaveBeenCalledWith("hermes", {
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
});
expect(deps.calls.updateSandbox).toHaveBeenCalledWith(
"hermes",
expect.objectContaining({
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
}),
);
expect(deps.getSession()).toMatchObject({
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
Expand Down Expand Up @@ -586,7 +605,16 @@ describe("runInferenceSet", () => {
},
},
};
const deps = createDeps({ config, session: baseSession() });
const deps = createDeps({
config,
session: baseSession({
provider: "compatible-anthropic-endpoint",
model: "claude-sonnet-proxy",
endpointUrl: "https://anthropic-compatible.example/v1",
credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
preferredInferenceApi: "anthropic-messages",
}),
});

const result = await runInferenceSet(
{
Expand Down Expand Up @@ -616,6 +644,16 @@ describe("runInferenceSet", () => {
},
},
});
expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([
"alpha",
expect.objectContaining({
provider: "compatible-anthropic-endpoint",
model: "claude-sonnet-proxy",
endpointUrl: "https://anthropic-compatible.example/v1",
credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
preferredInferenceApi: "anthropic-messages",
}),
]);
expect(deps.getSession()).toMatchObject({
provider: "compatible-anthropic-endpoint",
model: "claude-sonnet-proxy",
Expand All @@ -627,6 +665,34 @@ describe("runInferenceSet", () => {
});
});

it("rejects custom-compatible provider switches without trusted endpoint metadata", async () => {
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",
endpointUrl: "https://integrate.api.nvidia.com/v1",
credentialEnv: "NVIDIA_INFERENCE_API_KEY",
}),
});

await expect(
runInferenceSet(
{ provider: "compatible-endpoint", model: "openai/gpt-5.4-mini", noVerify: true },
deps,
),
).rejects.toThrow(/without trusted durable endpoint metadata/);

expect(deps.calls.runOpenshell).not.toHaveBeenCalled();
expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
});

it("preserves same-provider Bedrock Runtime adapter routing for OpenClaw switches", async () => {
const config: ConfigObject = {
agents: {
Expand Down Expand Up @@ -719,8 +785,11 @@ describe("runInferenceSet", () => {
session: baseSession({
agent: "hermes",
sandboxName: "hermes",
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
provider: "compatible-anthropic-endpoint",
model: "claude-sonnet-proxy",
endpointUrl: "https://anthropic-compatible.example/v1",
credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
preferredInferenceApi: "anthropic-messages",
}),
});

Expand All @@ -747,6 +816,16 @@ describe("runInferenceSet", () => {
provider: "compatible-anthropic-endpoint",
model: "claude-sonnet-proxy",
});
expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([
"hermes",
expect.objectContaining({
provider: "compatible-anthropic-endpoint",
model: "claude-sonnet-proxy",
endpointUrl: "https://anthropic-compatible.example/v1",
credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
preferredInferenceApi: "anthropic-messages",
}),
]);
expect(deps.getSession()).toMatchObject({
provider: "compatible-anthropic-endpoint",
model: "claude-sonnet-proxy",
Expand Down Expand Up @@ -823,10 +902,13 @@ describe("runInferenceSet", () => {
await runInferenceSet({ provider: "hermes-provider", model: "z-ai/glm-5.1" }, deps);

expect(deps.calls.writeSandboxConfig).toHaveBeenCalledWith("hermes-one", HERMES_TARGET, config);
expect(deps.calls.updateSandbox).toHaveBeenCalledWith("hermes-one", {
provider: "hermes-provider",
model: "z-ai/glm-5.1",
});
expect(deps.calls.updateSandbox).toHaveBeenCalledWith(
"hermes-one",
expect.objectContaining({
provider: "hermes-provider",
model: "z-ai/glm-5.1",
}),
);
});

it("requires --sandbox when the nemohermes alias cannot choose one Hermes sandbox", async () => {
Expand Down Expand Up @@ -876,6 +958,33 @@ describe("runInferenceSet", () => {
expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
});

it("keeps gateway and registry consistent when the sandbox config read fails", async () => {
const deps = createDeps({ config: {}, session: baseSession() });
deps.calls.readSandboxConfig.mockImplementation(() => {
throw new Error("sandbox config unreadable");
});

await expect(
runInferenceSet(
{ provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", noVerify: true },
deps,
),
).rejects.toThrow("sandbox config unreadable");

expect(deps.calls.updateSandbox).toHaveBeenCalledWith(
"alpha",
expect.objectContaining({
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
endpointUrl: null,
credentialEnv: null,
preferredInferenceApi: null,
nimContainer: null,
}),
);
expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled();
});

it("keeps gateway and registry consistent when the in-sandbox config write fails (#3726)", async () => {
const config: ConfigObject = {
agents: { defaults: { model: { primary: "inference/moonshotai/kimi-k2.6" } } },
Expand All @@ -899,10 +1008,13 @@ describe("runInferenceSet", () => {
);

// Registry still updated despite the in-sandbox sync throwing (no stale registry → no revert).
expect(deps.calls.updateSandbox).toHaveBeenCalledWith("alpha", {
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
});
expect(deps.calls.updateSandbox).toHaveBeenCalledWith(
"alpha",
expect.objectContaining({
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
}),
);
expect(deps.calls.recomputeSandboxConfigHash).not.toHaveBeenCalled();
expect(result).toMatchObject({
provider: "nvidia-prod",
Expand Down Expand Up @@ -940,10 +1052,13 @@ describe("runInferenceSet", () => {

// Config write happened and registry is updated; the run resolves without aborting.
expect(deps.calls.writeSandboxConfig).toHaveBeenCalled();
expect(deps.calls.updateSandbox).toHaveBeenCalledWith("alpha", {
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
});
expect(deps.calls.updateSandbox).toHaveBeenCalledWith(
"alpha",
expect.objectContaining({
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
}),
);
expect(result).toMatchObject({ inSandboxConfigSynced: false });

// Degraded: warns about the stale integrity hash, points at rebuild, no "synced".
Expand Down
99 changes: 95 additions & 4 deletions src/lib/actions/inference-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type SandboxInferenceConfig,
} from "../inference/config";
import { resolveContextWindowForModel } from "../inference/context-window";
import { inferenceSelectionRegistryFields } from "../inference/selection";
import { type ValidationResult, validateLocalProvider } from "../inference/local";
import { ensureLocalProviderReachable } from "../onboard/local-inference-topology";
import {
Expand Down Expand Up @@ -356,6 +357,71 @@ function getPreferredInferenceApi(config: ConfigObject): string | null {
return typeof inferenceProvider.api === "string" ? inferenceProvider.api : null;
}

type RegistryInferenceMetadata = Pick<
SandboxEntry,
"endpointUrl" | "credentialEnv" | "preferredInferenceApi" | "nimContainer"
>;

function isCustomCompatibleProvider(provider: string): boolean {
return provider === "compatible-endpoint" || provider === "compatible-anthropic-endpoint";
}

function matchingSessionMetadata(options: {
session: onboardSession.Session | null;
sandboxName: string;
provider: string;
model: string;
}): RegistryInferenceMetadata | null {
const { session, sandboxName, provider, model } = options;
if (
session?.sandboxName !== sandboxName ||
session.provider !== provider ||
session.model !== model ||
!session.endpointUrl
) {
return null;
}
return {
endpointUrl: session.endpointUrl,
credentialEnv: session.credentialEnv ?? null,
preferredInferenceApi: session.preferredInferenceApi ?? null,
nimContainer: session.nimContainer ?? null,
};
}

function registryMetadataForProviderSwitch(options: {
entry: SandboxEntry;
provider: string;
model: string;
sandboxName: string;
session: onboardSession.Session | null;
}): RegistryInferenceMetadata {
const { entry, provider, model, sandboxName, session } = options;
if (entry.provider === provider) {
return {
endpointUrl: entry.endpointUrl ?? null,
credentialEnv: entry.credentialEnv ?? null,
preferredInferenceApi: entry.preferredInferenceApi ?? null,
nimContainer: entry.nimContainer ?? null,
};
}
const sessionMetadata = matchingSessionMetadata({ session, sandboxName, provider, model });
if (sessionMetadata) return sessionMetadata;
if (isCustomCompatibleProvider(provider)) {
throw new InferenceSetError(
`Cannot switch sandbox '${sandboxName}' to '${provider}' without trusted durable endpoint metadata. ` +
`Re-run onboarding for this custom endpoint or restore a matching onboard session before using inference set.`,
2,
);
}
return {
endpointUrl: null,
credentialEnv: null,
preferredInferenceApi: null,
nimContainer: null,
};
}

export async function runInferenceSet(
options: InferenceSetOptions,
deps: InferenceSetDeps = defaultDeps(),
Expand Down Expand Up @@ -385,6 +451,14 @@ export async function runInferenceSet(
2,
);
}
const session = deps.loadSession();
const registryMetadata = registryMetadataForProviderSwitch({
entry,
provider,
model,
sandboxName,
session,
});

// Local providers (ollama-local, vllm-local) route through the sandbox-facing
// host.openshell.internal hostname, which the host-side `openshell inference set`
Expand Down Expand Up @@ -429,9 +503,20 @@ export async function runInferenceSet(
);
}

// Write the registry before the crash-prone in-sandbox sync so the gateway
// and registry can't end up split (#3725) and trigger a revert on connect (#3726).
if (!deps.updateSandbox(sandboxName, { provider, model })) {
// Write minimal registry state before any sandbox-facing config read so the
// gateway and registry cannot split if the in-sandbox layer is unavailable.
const registryFields = (preferredInferenceApi: string | null) =>
inferenceSelectionRegistryFields({
provider,
model,
endpointUrl: registryMetadata.endpointUrl ?? null,
credentialEnv: registryMetadata.credentialEnv ?? null,
preferredInferenceApi,
nimContainer: registryMetadata.nimContainer ?? null,
});
if (
!deps.updateSandbox(sandboxName, registryFields(registryMetadata.preferredInferenceApi ?? null))
) {
throw new InferenceSetError(`Failed to update NemoClaw registry for sandbox '${sandboxName}'.`);
}

Expand All @@ -442,8 +527,14 @@ export async function runInferenceSet(
currentProvider: entry.provider,
provider,
sandboxName,
session: deps.loadSession(),
session,
});
// Refresh the registry with config-derived API-family metadata before the
// crash-prone in-sandbox sync (#3725/#3726).
if (!deps.updateSandbox(sandboxName, registryFields(preferredInferenceApi))) {
throw new InferenceSetError(`Failed to update NemoClaw registry for sandbox '${sandboxName}'.`);
}

let patched: { changed: boolean; route: SandboxInferenceConfig };
if (agentName === "hermes") {
patched = patchHermesInferenceConfig(config, provider, model, preferredInferenceApi);
Expand Down
Loading
Loading