diff --git a/docs/inference/switch-inference-providers.mdx b/docs/inference/switch-inference-providers.mdx index b2970dca225..ac888f36677 100644 --- a/docs/inference/switch-inference-providers.mdx +++ b/docs/inference/switch-inference-providers.mdx @@ -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. diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index e8fb8ce4c4d..4521637170c 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -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; @@ -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, @@ -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" } } }, @@ -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: { diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 2f376134dcf..bfee59f772e 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -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, @@ -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, @@ -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, @@ -104,6 +138,7 @@ function defaultDeps(): InferenceSetDeps { loadSession: onboardSession.loadSession, updateSession: onboardSession.updateSession, resolveAgentConfig, + resolveContextWindow: defaultResolveContextWindow, readSandboxConfig, writeSandboxConfig, recomputeSandboxConfigHash, @@ -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]) @@ -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, @@ -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); @@ -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 }; } @@ -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( diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index 26f3985f961..783dfe429a4 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -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", diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index 1ab0146c575..5a8327d7234 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -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"); @@ -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, @@ -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}`); diff --git a/src/lib/inference/vllm-runtime-context.ts b/src/lib/inference/vllm-runtime-context.ts index 490ede519e8..ef17f751676 100644 --- a/src/lib/inference/vllm-runtime-context.ts +++ b/src/lib/inference/vllm-runtime-context.ts @@ -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 }; +/** + * 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,