Skip to content
Closed
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
4 changes: 4 additions & 0 deletions docs/inference/switch-inference-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ To change these values, set the corresponding environment variables before runni
NemoClaw ignores invalid values and bakes the default into the image.
For Local Ollama, onboarding loads the selected model first and uses Ollama's reported runtime context length when `NEMOCLAW_CONTEXT_WINDOW` is unset.
For local vLLM, onboarding uses the runtime `max_model_len` value when the server reports one and `NEMOCLAW_CONTEXT_WINDOW` is unset.

`$$nemoclaw inference set` recomputes the context window the same way when it changes the model, so a switch never carries the previous model's window into the new model's config.
It probes the Ollama runtime length for `ollama-local`, reads `max_model_len` for `vllm-local`, and applies the `131072` cloud default otherwise.
The other model-metadata values (max output tokens, reasoning mode, accepted input modalities) are still baked at build time and require re-onboarding to change.
Use `NEMOCLAW_INFERENCE_INPUTS=text,image` only for a model that accepts image input through the selected provider.
During interactive onboarding, NemoClaw prompts for **Text only** or **Text + Image** when the discovered model name looks multimodal and `NEMOCLAW_INFERENCE_INPUTS` is not already valid.
Non-interactive onboarding uses the environment value or the default `text` setting.
Expand Down
96 changes: 96 additions & 0 deletions src/lib/actions/inference-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ function createDeps(options: {
target?: AgentConfigTarget;
session?: Session | null;
openshellStatus?: number;
resolveContextWindow?: (provider: string, model: string) => number | null;
}): InferenceSetDeps & {
calls: {
runOpenshell: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -146,6 +147,7 @@ function createDeps(options: {
loadSession: () => session,
updateSession: calls.updateSession,
resolveAgentConfig: () => options.target ?? OPENCLAW_TARGET,
resolveContextWindow: options.resolveContextWindow ?? (() => null),
readSandboxConfig: () => options.config,
writeSandboxConfig: calls.writeSandboxConfig,
recomputeSandboxConfigHash: calls.recomputeSandboxConfigHash,
Expand Down Expand Up @@ -214,6 +216,64 @@ describe("patchOpenClawInferenceConfig", () => {
});
});

it("replaces a stale context window with the recomputed window on switch (#5456)", () => {
const config: ConfigObject = {
agents: { defaults: { model: { primary: "inference/nvidia/nemotron-3-super-120b-a12b" } } },
models: {
mode: "merge",
providers: {
inference: {
baseUrl: "https://inference.local/v1",
apiKey: "unused",
api: "openai-completions",
models: [
{
id: "nvidia/nemotron-3-super-120b-a12b",
name: "inference/nvidia/nemotron-3-super-120b-a12b",
contextWindow: 131072,
},
],
},
},
},
};

patchOpenClawInferenceConfig(config, "ollama-local", "qwen2.5:7b", null, 16384);

const models = ((config.models as ConfigObject).providers as ConfigObject)
.inference as ConfigObject;
expect((models.models as ConfigObject[])[0].contextWindow).toBe(16384);
});

it("drops a stale context window when no runtime window is available on switch (#5456)", () => {
const config: ConfigObject = {
agents: { defaults: { model: { primary: "inference/nvidia/nemotron-3-super-120b-a12b" } } },
models: {
mode: "merge",
providers: {
inference: {
baseUrl: "https://inference.local/v1",
apiKey: "unused",
api: "openai-completions",
models: [
{
id: "nvidia/nemotron-3-super-120b-a12b",
name: "inference/nvidia/nemotron-3-super-120b-a12b",
contextWindow: 131072,
},
],
},
},
},
};

patchOpenClawInferenceConfig(config, "ollama-local", "qwen2.5:7b", null, null);

const provider = ((config.models as ConfigObject).providers as ConfigObject)
.inference as ConfigObject;
expect((provider.models as ConfigObject[])[0].contextWindow).toBeUndefined();
});

it("is a no-op when OpenClaw already matches the requested route", () => {
const config: ConfigObject = {
agents: { defaults: { model: { primary: "inference/nvidia/model-a" } } },
Expand Down Expand Up @@ -454,6 +514,42 @@ describe("runInferenceSet", () => {
});
});

it("recomputes the OpenClaw context window on model switch instead of carrying the stale value (#5456)", async () => {
const config: ConfigObject = {
agents: { defaults: { model: { primary: "inference/nvidia/nemotron-3-super-120b-a12b" } } },
models: {
mode: "merge",
providers: {
inference: {
baseUrl: "https://inference.local/v1",
apiKey: "unused",
api: "openai-completions",
models: [
{
id: "nvidia/nemotron-3-super-120b-a12b",
name: "inference/nvidia/nemotron-3-super-120b-a12b",
contextWindow: 131072,
},
],
},
},
},
};
const deps = createDeps({
config,
session: baseSession(),
resolveContextWindow: () => 16384,
});

await runInferenceSet({ provider: "ollama-local", model: "qwen2.5:7b", noVerify: true }, deps);

const provider = ((config.models as ConfigObject).providers as ConfigObject)
.inference as ConfigObject;
const model = (provider.models as ConfigObject[])[0];
expect(model.id).toBe("qwen2.5:7b");
expect(model.contextWindow).toBe(16384);
});

it("updates OpenShell, Hermes config.yaml, registry, and the matching onboard session", async () => {
const config: ConfigObject = {
model: {
Expand Down
53 changes: 52 additions & 1 deletion src/lib/actions/inference-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ import { runOpenshell } from "../adapters/openshell/runtime";
import { CLI_NAME } from "../cli/branding";
import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../hermes-proxy-api-key";
import {
DEFAULT_CLOUD_CONTEXT_WINDOW,
getProviderSelectionConfig,
getSandboxInferenceConfig,
type SandboxInferenceConfig,
} from "../inference/config";
import {
MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
resolveOllamaRuntimeContextWindow,
resolveVllmRuntimeContextWindow,
} from "../inference/local";
import {
type AgentConfigTarget,
readSandboxConfig,
Expand Down Expand Up @@ -58,6 +64,13 @@ export interface InferenceSetDeps {
mutator: (session: onboardSession.Session) => onboardSession.Session | void,
) => onboardSession.Session;
resolveAgentConfig: (sandboxName: string) => AgentConfigTarget;
/**
* Recompute the context window for the target route on a model switch.
* Returns a positive token count, or `null` when no runtime window is
* available (probe failed / model not loaded) so the stale value is dropped
* rather than carried over. (#5456)
*/
resolveContextWindow: (provider: string, model: string) => number | null;
readSandboxConfig: (sandboxName: string, target: AgentConfigTarget) => ConfigObject;
writeSandboxConfig: (
sandboxName: string,
Expand Down Expand Up @@ -94,6 +107,27 @@ const SUPPORTED_PROVIDER_NAMES = [
"vllm-local",
] as const;

/**
* Recompute the context window for a switched route, mirroring what onboard
* does per provider (#5456):
* - ollama-local → probe the daemon's runtime context length, floored at the
* agent-usable minimum (matches applyOllamaRuntimeContextWindow).
* - vllm-local → read the served model's max_model_len from /v1/models.
* - cloud → flat default; NemoClaw has no per-model cloud metadata.
* Returns `null` when a local probe yields nothing so the caller drops the
* stale window instead of carrying the previous model's value.
*/
function defaultResolveContextWindow(provider: string, model: string): number | null {
if (provider === "ollama-local") {
const detected = resolveOllamaRuntimeContextWindow(model, null);
return detected === null ? null : Math.max(detected, MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW);
}
if (provider === "vllm-local") {
return resolveVllmRuntimeContextWindow(model);
}
return DEFAULT_CLOUD_CONTEXT_WINDOW;
}

function defaultDeps(): InferenceSetDeps {
return {
getDefaultSandbox: registry.getDefault,
Expand All @@ -104,6 +138,7 @@ function defaultDeps(): InferenceSetDeps {
loadSession: onboardSession.loadSession,
updateSession: onboardSession.updateSession,
resolveAgentConfig,
resolveContextWindow: defaultResolveContextWindow,
readSandboxConfig,
writeSandboxConfig,
recomputeSandboxConfigHash,
Expand Down Expand Up @@ -221,6 +256,7 @@ function buildProviderConfig(
existing: ConfigObject,
model: string,
route: SandboxInferenceConfig,
contextWindow: number | null | undefined,
): ConfigObject {
const firstExistingModel = Array.isArray(existing.models)
? cloneConfigObject(existing.models[0])
Expand All @@ -231,6 +267,17 @@ function buildProviderConfig(
if (route.inferenceCompat) {
firstExistingModel.compat = asConfigObject(route.inferenceCompat);
}
// Recompute the context window on switch so the prior model's window does not
// carry over (#5456). `undefined` preserves the existing value (direct callers
// that don't recompute); `null` drops a now-stale window; a positive number
// replaces it.
if (contextWindow !== undefined) {
if (typeof contextWindow === "number" && contextWindow > 0) {
firstExistingModel.contextWindow = contextWindow;
} else {
delete firstExistingModel.contextWindow;
}
}

return {
...existing,
Expand All @@ -246,6 +293,7 @@ export function patchOpenClawInferenceConfig(
provider: string,
model: string,
preferredInferenceApi: string | null = null,
contextWindow: number | null | undefined = undefined,
): { changed: boolean; route: SandboxInferenceConfig } {
const before = JSON.stringify(config);
const route = getSandboxInferenceConfig(model, provider, preferredInferenceApi);
Expand All @@ -256,7 +304,7 @@ export function patchOpenClawInferenceConfig(
models.mode = "merge";
const providers = ensureObject(models, "providers");
const existingProvider = cloneConfigObject(providers[route.providerKey]);
providers[route.providerKey] = buildProviderConfig(existingProvider, model, route);
providers[route.providerKey] = buildProviderConfig(existingProvider, model, route, contextWindow);

return { changed: before !== JSON.stringify(config), route };
}
Expand Down Expand Up @@ -404,6 +452,9 @@ export async function runInferenceSet(
provider,
model,
preferredInferenceApi || getPreferredInferenceApi(config),
// Recompute the window per provider so the prior model's contextWindow
// does not carry into the new model's config (#5456).
deps.resolveContextWindow(provider, model),
);

deps.log(
Expand Down
5 changes: 5 additions & 0 deletions src/lib/inference/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export const INFERENCE_ROUTE_URL = "https://inference.local/v1";
export const NOUS_RECOMMENDED_MODELS_URL =
"https://portal.nousresearch.com/api/nous/recommended-models";
export const DEFAULT_CLOUD_MODEL = "nvidia/nemotron-3-super-120b-a12b";
// Flat context-window default applied to cloud models. NemoClaw has no
// per-model cloud context metadata, so onboard and `inference set` both fall
// back to this value (mirrors NEMOCLAW_CONTEXT_WINDOW's default in
// generate-openclaw-config.mts). (#5456)
export const DEFAULT_CLOUD_CONTEXT_WINDOW = 131072;
export const HERMES_PROVIDER_MODEL_OPTIONS = [
"moonshotai/kimi-k2.6",
"xiaomi/mimo-v2.5-pro",
Expand Down
52 changes: 48 additions & 4 deletions src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,26 @@
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import type { CurlProbeResult } from "../adapters/http/probe";
import { buildValidatedCurlCommandArgs } from "../adapters/http/curl-args";
import type { CurlProbeResult } from "../adapters/http/probe";
import { runCurlProbe } from "../adapters/http/probe";
import type { CaptureResult } from "../runner";
import { buildSubprocessEnv } from "../subprocess-env";
import type { OllamaRuntimeModelStatus } from "./ollama-runtime-context";
import {
applyOllamaRuntimeContextWindow as applyOllamaRuntimeContextWindowWithHost,
MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
parsePositiveInteger,
probeOllamaRuntimeModelStatus as probeOllamaRuntimeModelStatusWithHost,
resetOllamaRuntimeContextWindowAutoState,
resolveOllamaRuntimeContextWindow as resolveOllamaRuntimeContextWindowWithHost,
} from "./ollama-runtime-context";
import type { OllamaRuntimeModelStatus } from "./ollama-runtime-context";
import { applyVllmRuntimeContextWindow as applyVllmRuntimeContextWindowFromModels } from "./vllm-runtime-context";
import {
applyVllmRuntimeContextWindow as applyVllmRuntimeContextWindowFromModels,
resolveVllmRuntimeContextWindow as resolveVllmRuntimeContextWindowFromModels,
} from "./vllm-runtime-context";

export type { OllamaRuntimeModelStatus } from "./ollama-runtime-context";

const { shellQuote, runCapture, runCaptureEx } = require("../runner");
Expand Down Expand Up @@ -774,7 +779,11 @@ export function parseOllamaTags(output: string | null | undefined): string[] {
}
}

export { MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW, parsePositiveInteger };
export {
MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
parsePositiveInteger,
};

export function probeOllamaRuntimeModelStatus(
model: string,
Expand Down Expand Up @@ -809,6 +818,41 @@ export function applyVllmRuntimeContextWindow(
applyVllmRuntimeContextWindowFromModels(modelsResponse, modelId);
}

/**
* Probe the local vLLM server's `/v1/models` endpoint and resolve its runtime
* context window (`max_model_len`). Returns `null` when vLLM is unreachable or
* the response carries no usable value. Used by `inference set` to recompute
* the window on a model switch instead of carrying the prior value (#5456).
*/
export function resolveVllmRuntimeContextWindow(
modelId: string | null | undefined = null,
runCaptureImpl?: RunCaptureFn,
): number | null {
const capture = runCaptureImpl ?? runCapture;
const raw = capture(
[
"curl",
...buildValidatedCurlCommandArgs([
"-sf",
"--connect-timeout",
"3",
"--max-time",
"5",
`http://127.0.0.1:${VLLM_PORT}/v1/models`,
]),
],
{ ignoreError: true },
);
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(String(raw));
} catch {
return null;
}
return resolveVllmRuntimeContextWindowFromModels(parsed, modelId);
}

function formatOllamaCpuOnlyDiagnostic(model: string, status: OllamaRuntimeModelStatus): string {
const observed: string[] = [];
if (status.processor) observed.push(`processor=${status.processor}`);
Expand Down
38 changes: 38 additions & 0 deletions src/lib/inference/vllm-runtime-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,44 @@ const MAX_AUTODETECTED_VLLM_CONTEXT_WINDOW = 4_194_304;
type ModelEntry = { id?: unknown; max_model_len?: unknown };
type ApplyOptions = { env?: NodeJS.ProcessEnv; logger?: Pick<Console, "log" | "warn"> };

/**
* Resolve the vLLM runtime context window from a `/v1/models` response without
* touching process env. Returns the served model's `max_model_len` as a
* positive integer, or `null` when the response is empty, the field is
* omitted/malformed, or the value is above NemoClaw's auto-detect ceiling.
*
* Shares the same validation as `applyVllmRuntimeContextWindow`; that function
* stays responsible for the onboard env-mutation + logging path, while this
* pure helper backs the `inference set` recompute (#5456).
*/
export function resolveVllmRuntimeContextWindow(
modelsResponse: unknown,
modelId: string | null | undefined,
): number | null {
const data = (modelsResponse as { data?: unknown } | null | undefined)?.data;
const entries = Array.isArray(data) ? (data as ModelEntry[]) : [];
if (entries.length === 0) return null;

const target = String(modelId ?? "").trim();
const entry =
(target && entries.find((candidate) => String(candidate.id ?? "").trim() === target)) ||
entries[0];
const rawMaxModelLen = entry?.max_model_len;
if (
rawMaxModelLen === undefined ||
rawMaxModelLen === null ||
String(rawMaxModelLen).trim() === ""
) {
return null;
}

const contextLength = parsePositiveInteger(rawMaxModelLen);
if (!contextLength || contextLength > MAX_AUTODETECTED_VLLM_CONTEXT_WINDOW) {
return null;
}
return contextLength;
}

export function applyVllmRuntimeContextWindow(
modelsResponse: unknown,
modelId: string | null | undefined,
Expand Down
Loading