diff --git a/Dockerfile b/Dockerfile index 414260fb681..99c23432b21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -206,6 +206,10 @@ ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_CONTEXT_WINDOW=131072 ARG NEMOCLAW_MAX_TOKENS=4096 ARG NEMOCLAW_REASONING=false +# Comma-separated list of input modalities accepted by the primary model +# (e.g. "text" or "text,image" for vision-capable models). OpenClaw's +# model schema currently accepts "text" and "image". See #2421. +ARG NEMOCLAW_INFERENCE_INPUTS=text # Per-request inference timeout (seconds) baked into agents.defaults.timeoutSeconds. # Increase for slow local inference (e.g., CPU Ollama). openclaw.json is # immutable at runtime (Landlock read-only), so this can only be changed by @@ -255,6 +259,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_CONTEXT_WINDOW=${NEMOCLAW_CONTEXT_WINDOW} \ NEMOCLAW_MAX_TOKENS=${NEMOCLAW_MAX_TOKENS} \ NEMOCLAW_REASONING=${NEMOCLAW_REASONING} \ + NEMOCLAW_INFERENCE_INPUTS=${NEMOCLAW_INFERENCE_INPUTS} \ NEMOCLAW_AGENT_TIMEOUT=${NEMOCLAW_AGENT_TIMEOUT} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ @@ -304,6 +309,7 @@ inference_api = os.environ['NEMOCLAW_INFERENCE_API']; \ context_window = int(os.environ.get('NEMOCLAW_CONTEXT_WINDOW', '131072')); \ max_tokens = int(os.environ.get('NEMOCLAW_MAX_TOKENS', '4096')); \ reasoning = os.environ.get('NEMOCLAW_REASONING', 'false') == 'true'; \ +inference_inputs = [v.strip() for v in os.environ.get('NEMOCLAW_INFERENCE_INPUTS', 'text').split(',') if v.strip()] or ['text']; \ _raw_agent_timeout = os.environ.get('NEMOCLAW_AGENT_TIMEOUT', '600'); \ agent_timeout = int(_raw_agent_timeout) if _raw_agent_timeout.isdigit() and int(_raw_agent_timeout) > 0 else (_ for _ in ()).throw(ValueError('NEMOCLAW_AGENT_TIMEOUT must be a positive integer')); \ inference_compat = json.loads(base64.b64decode(os.environ['NEMOCLAW_INFERENCE_COMPAT_B64']).decode('utf-8')); \ @@ -325,7 +331,7 @@ providers = { \ 'baseUrl': inference_base_url, \ 'apiKey': 'unused', \ 'api': inference_api, \ - 'models': [{**({'compat': inference_compat} if inference_compat else {}), 'id': model, 'name': primary_model_ref, 'reasoning': reasoning, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': context_window, 'maxTokens': max_tokens}] \ + 'models': [{**({'compat': inference_compat} if inference_compat else {}), 'id': model, 'name': primary_model_ref, 'reasoning': reasoning, 'input': inference_inputs, 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': context_window, 'maxTokens': max_tokens}] \ } \ }; \ config = { \ diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8567fe81446..fd93bf95c9c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1551,6 +1551,17 @@ function patchStagedDockerfile( `ARG NEMOCLAW_REASONING=${reasoning}`, ); } + // Honor NEMOCLAW_INFERENCE_INPUTS for vision-capable models. OpenClaw's + // model schema currently accepts "text" and "image" only, so validate + // strictly against that vocabulary. Adding modalities to OpenClaw later + // only requires widening this regex. See #2421. + const inferenceInputs = process.env.NEMOCLAW_INFERENCE_INPUTS; + if (inferenceInputs && /^(text|image)(,(text|image))*$/.test(inferenceInputs)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_INPUTS=.*$/m, + `ARG NEMOCLAW_INFERENCE_INPUTS=${inferenceInputs}`, + ); + } // NEMOCLAW_AGENT_TIMEOUT — override agents.defaults.timeoutSeconds at build // time. Lets users increase the per-request inference timeout without // editing the Dockerfile. Ref: issue #2281 diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 7c6e95f3cfe..3e29b7dcc69 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -776,6 +776,106 @@ describe("onboard helpers", () => { } }); + it("regression #2421: bakes NEMOCLAW_INFERENCE_INPUTS into the staged Dockerfile when env is set", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-inputs-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_BUILD_ID=default", + "ARG NEMOCLAW_INFERENCE_INPUTS=text", + ].join("\n"), + ); + + const prior = process.env.NEMOCLAW_INFERENCE_INPUTS; + process.env.NEMOCLAW_INFERENCE_INPUTS = "text,image"; + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + "build-inputs", + "openai-api", + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_INFERENCE_INPUTS=text,image$/m); + } finally { + if (prior === undefined) { + delete process.env.NEMOCLAW_INFERENCE_INPUTS; + } else { + process.env.NEMOCLAW_INFERENCE_INPUTS = prior; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("regression #2421: rejects malformed NEMOCLAW_INFERENCE_INPUTS and keeps default", () => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-inputs-bad-"), + ); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + const baseDockerfile = [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_BUILD_ID=default", + "ARG NEMOCLAW_INFERENCE_INPUTS=text", + ].join("\n"); + + const prior = process.env.NEMOCLAW_INFERENCE_INPUTS; + try { + // Cases that must all leave the default untouched. + const rejectCases = [ + undefined, + "audio", + "text,", + "Text,Image", + "text, image", + 'text"\nRUN rm -rf /', + ]; + for (const [index, value] of rejectCases.entries()) { + fs.writeFileSync(dockerfilePath, baseDockerfile); + if (value === undefined) { + delete process.env.NEMOCLAW_INFERENCE_INPUTS; + } else { + process.env.NEMOCLAW_INFERENCE_INPUTS = value; + } + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + `build-inputs-reject-${index}`, + "openai-api", + ); + assert.match( + fs.readFileSync(dockerfilePath, "utf8"), + /^ARG NEMOCLAW_INFERENCE_INPUTS=text$/m, + `value="${String(value)}" should not change the ARG default`, + ); + } + } finally { + if (prior === undefined) { + delete process.env.NEMOCLAW_INFERENCE_INPUTS; + } else { + process.env.NEMOCLAW_INFERENCE_INPUTS = prior; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("regression #1409: rejects malformed NEMOCLAW_PROXY_HOST/PORT and keeps defaults", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-proxy-bad-")); const dockerfilePath = path.join(tmpDir, "Dockerfile");