diff --git a/nemoclaw-blueprint/scripts/nemotron-inference-fix.js b/nemoclaw-blueprint/scripts/nemotron-inference-fix.js index e0a53fc3fe6..dc9a26ded66 100644 --- a/nemoclaw-blueprint/scripts/nemotron-inference-fix.js +++ b/nemoclaw-blueprint/scripts/nemotron-inference-fix.js @@ -98,10 +98,13 @@ // field (the endpoint returns `reasoning_content` either way), so the field // carries no behavior to preserve. // -// Scope: the exact `nvidia/nemotron-3-ultra-550b-a55b` model ID reproduced -// in #6913. Prefix collisions and other Nemotron-3 IDs preserve their -// top-level `thinking` field unless their own accepted scope establishes the -// same endpoint contract. +// Scope: the `nvidia/nemotron-3-` model family when the managed +// `inference.local` route currently selects nvidia-prod. The image-baked +// NEMOCLAW_UPSTREAM_PROVIDER establishes the onboarding route; after an +// in-place provider switch, OpenClaw's private request marker is authoritative +// and this preload removes it before forwarding. Ultra, Super, and Nano all +// reject the top-level field on NVIDIA Build, while unrelated models and +// other upstream providers must remain untouched. // // Source boundary: NemoClaw owns the sandbox preload that wraps outgoing // chat-completions traffic. The `thinking` field originates in OpenClaw's @@ -134,6 +137,9 @@ var https = require('https'); var COMPLETIONS_RE = /\/v1\/chat\/completions/; + // Written only by NemoClaw's runtime config synchronization. This is + // non-secret control metadata and must not leave the sandbox. + var UPSTREAM_PROVIDER_HEADER = 'x-nemoclaw-upstream-provider'; var CHAT_TEMPLATE_KWARG_RULES = [ { pattern: /nemotron/i, kwargs: { force_nonempty_content: true } }, { pattern: /^deepseek-ai\/deepseek-v4-pro$/i, kwargs: { thinking: false } }, @@ -145,9 +151,9 @@ // set `chat_template_kwargs.thinking` (a chat-template arg the endpoint // accepts); this strips the *top-level* `thinking` request field entirely. // - // Scope is the exact Ultra model ID accepted by #6913. Do not infer support - // for suffix variants or other Nemotron-3 models from this workaround. - var STRIP_TOP_LEVEL_THINKING_RE = /^nvidia\/nemotron-3-ultra-550b-a55b$/i; + // Scope is the Nemotron-3 family verified by #6913. Do not widen this to all + // NVIDIA endpoint models: some models accept and use top-level `thinking`. + var STRIP_TOP_LEVEL_THINKING_RE = /^nvidia\/nemotron-3-/i; // #4851: Ultra 550B silently drops intermediate steps from `content` when // asked to perform multi-step tasks without execution-capable tools — @@ -310,12 +316,12 @@ return true; } - function patchJsonBody(raw) { + function patchJsonBody(raw, stripTopLevelThinking) { try { var body = JSON.parse(raw.toString('utf-8')); var changed = false; if (applyChatTemplateKwargs(body)) changed = true; - if (applyStripTopLevelThinking(body)) changed = true; + if (stripTopLevelThinking && applyStripTopLevelThinking(body)) changed = true; if (applyToolLessSystemPrompt(body)) changed = true; if (!changed) return null; return Buffer.from(JSON.stringify(body), 'utf-8'); @@ -346,21 +352,81 @@ return isChatCompletionsPost(fetchMethod(input, init), fetchUrl(input)); } - function headersWithoutContentLength(headers) { + function upstreamProviderFromHeaders(headers) { + if (!headers) return undefined; + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + return headers.has(UPSTREAM_PROVIDER_HEADER) + ? headers.get(UPSTREAM_PROVIDER_HEADER) + : undefined; + } + if (Array.isArray(headers)) { + for (var i = 0; i < headers.length; i++) { + var entry = headers[i]; + if ( + entry && + String(entry[0]).toLowerCase() === UPSTREAM_PROVIDER_HEADER + ) { + return String(entry[1]); + } + } + return undefined; + } + if (typeof headers === 'object') { + var keys = Object.keys(headers); + for (var j = 0; j < keys.length; j++) { + if (keys[j].toLowerCase() === UPSTREAM_PROVIDER_HEADER) { + return String(headers[keys[j]]); + } + } + } + return undefined; + } + + function isNvidiaBuildUpstream(upstreamProvider) { + var provider = + upstreamProvider === undefined + ? process.env.NEMOCLAW_UPSTREAM_PROVIDER + : upstreamProvider; + return provider === 'nvidia-prod'; + } + + function isManagedBuildHost(host, upstreamProvider) { + return ( + isNvidiaBuildUpstream(upstreamProvider) && + /^inference\.local(?::\d+)?$/i.test(String(host || '')) + ); + } + + function isManagedBuildFetch(input, upstreamProvider) { + if (!isNvidiaBuildUpstream(upstreamProvider)) return false; + try { + return new URL(fetchUrl(input)).hostname.toLowerCase() === 'inference.local'; + } catch (_e) { + return false; + } + } + + function isManagedBuildRequest(options, upstreamProvider) { + return isManagedBuildHost(options.hostname || options.host, upstreamProvider); + } + + function headersWithoutNames(headers, names) { if (typeof Headers !== 'undefined' && headers instanceof Headers) { var copy = new Headers(headers); - copy.delete('content-length'); + names.forEach(function (name) { + copy.delete(name); + }); return copy; } if (Array.isArray(headers)) { return headers.filter(function (entry) { - return !entry || String(entry[0]).toLowerCase() !== 'content-length'; + return !entry || !names.has(String(entry[0]).toLowerCase()); }); } if (headers && typeof headers === 'object') { var next = {}; Object.keys(headers).forEach(function (key) { - if (key.toLowerCase() !== 'content-length') { + if (!names.has(key.toLowerCase())) { next[key] = headers[key]; } }); @@ -369,6 +435,14 @@ return headers; } + function headersWithoutContentLength(headers) { + return headersWithoutNames(headers, new Set(['content-length'])); + } + + function headersWithoutUpstreamProvider(headers) { + return headersWithoutNames(headers, new Set([UPSTREAM_PROVIDER_HEADER])); + } + function bytesFromSimpleBody(body) { if (typeof body === 'string') return Promise.resolve(Buffer.from(body, 'utf-8')); if (Buffer.isBuffer(body)) return Promise.resolve(body); @@ -421,19 +495,34 @@ var origFetch = globalThis.fetch; var wrappedFetch = async function (input, init) { + var nextInit = init ? Object.assign({}, init) : {}; + var effectiveHeaders = nextInit.headers || (input && input.headers); + var upstreamProvider = upstreamProviderFromHeaders(effectiveHeaders); + var hasUpstreamProviderMarker = upstreamProvider !== undefined; + if (hasUpstreamProviderMarker) { + nextInit.headers = headersWithoutUpstreamProvider(effectiveHeaders); + } if (!isChatCompletionsFetch(input, init)) { - return origFetch.apply(this, arguments); + return hasUpstreamProviderMarker + ? origFetch.call(this, input, nextInit) + : origFetch.apply(this, arguments); } - var nextInit = init ? Object.assign({}, init) : {}; var rawPromise = bytesFromFetch(input, nextInit); if (!rawPromise) { - return origFetch.apply(this, arguments); + return hasUpstreamProviderMarker + ? origFetch.call(this, input, nextInit) + : origFetch.apply(this, arguments); } - var modified = patchJsonBody(await rawPromise); + var modified = patchJsonBody( + await rawPromise, + isManagedBuildFetch(input, upstreamProvider) + ); if (!modified) { - return origFetch.apply(this, arguments); + return hasUpstreamProviderMarker + ? origFetch.call(this, input, nextInit) + : origFetch.apply(this, arguments); } nextInit.body = modified.toString('utf-8'); @@ -450,18 +539,23 @@ var origRequest = mod.request; mod.request = function (options, callback) { - // Only intercept object-form calls with a recognisable path. if (typeof options === 'string' || !options) { return origRequest.apply(mod, arguments); } + var upstreamProvider = upstreamProviderFromHeaders(options.headers); + var req = origRequest.apply(mod, arguments); + if (upstreamProvider !== undefined && req.removeHeader) { + req.removeHeader(UPSTREAM_PROVIDER_HEADER); + } + + // Only intercept object-form calls with a recognisable path. var path = options.path || ''; if (!isChatCompletionsPost(options.method, path)) { - return origRequest.apply(mod, arguments); + return req; } // Create the real request, then intercept write/end to buffer the body. - var req = origRequest.apply(mod, arguments); var origWrite = req.write; var origEnd = req.end; var chunks = []; @@ -479,7 +573,10 @@ } var raw = Buffer.concat(chunks); - var modified = patchJsonBody(raw); + var modified = patchJsonBody( + raw, + isManagedBuildRequest(options, upstreamProvider) + ); var bodyToSend = modified || raw; if (modified && req.getHeader && req.setHeader) { req.removeHeader('content-length'); diff --git a/src/lib/actions/inference-set-compatible-provider.test.ts b/src/lib/actions/inference-set-compatible-provider.test.ts index 31a69c06881..add41abe538 100644 --- a/src/lib/actions/inference-set-compatible-provider.test.ts +++ b/src/lib/actions/inference-set-compatible-provider.test.ts @@ -214,6 +214,9 @@ describe("runInferenceSet compatible providers", () => { providers: { inference: { api: "openai-responses", + headers: { + "X-NemoClaw-Upstream-Provider": "compatible-endpoint", + }, models: [{ id: "mock-responses-model", name: "inference/mock-responses-model" }], }, }, diff --git a/src/lib/actions/inference-set-degraded-state.test.ts b/src/lib/actions/inference-set-degraded-state.test.ts index 552279ee35f..be11b3ce0cb 100644 --- a/src/lib/actions/inference-set-degraded-state.test.ts +++ b/src/lib/actions/inference-set-degraded-state.test.ts @@ -94,6 +94,92 @@ describe("runInferenceSet degraded state handling", () => { expect(deps.calls.restartSandboxGateway).not.toHaveBeenCalled(); }); + it("reconciles the provider marker when a config-write retry uses the registered provider", async () => { + const entry = { + name: "alpha", + agent: "openclaw", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + }; + let persistedConfig: ConfigObject = { + agents: { + defaults: { model: { primary: "inference/nvidia/nemotron-3-super-120b-a12b" } }, + }, + models: { + providers: { + inference: { + baseUrl: "https://inference.local/v1", + api: "openai-completions", + headers: { + "X-NemoClaw-Upstream-Provider": "nvidia-prod", + }, + models: [ + { + id: "nvidia/nemotron-3-super-120b-a12b", + name: "inference/nvidia/nemotron-3-super-120b-a12b", + }, + ], + }, + }, + }, + }; + const deps = createDeps({ + config: structuredClone(persistedConfig), + entry, + session: baseSession({ + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + }), + }); + deps.calls.readSandboxConfig.mockImplementation(() => structuredClone(persistedConfig)); + deps.calls.updateSandbox.mockImplementation((_name, updates) => { + Object.assign(entry, updates); + return true; + }); + deps.calls.writeSandboxConfig + .mockImplementationOnce(() => { + throw new Error("sandbox exec crashed"); + }) + .mockImplementation((_name, _target, config) => { + persistedConfig = structuredClone(config); + }); + + const options = { + provider: "compatible-endpoint", + model: "openai/gpt-5.4-mini", + endpointUrl: "https://compatible.example/v1", + credentialEnv: "COMPATIBLE_API_KEY", + inferenceApi: "openai-completions", + noVerify: true, + }; + + await expect(runInferenceSet(options, deps)).resolves.toMatchObject({ + inSandboxConfigSynced: false, + }); + expect(persistedConfig.models).toMatchObject({ + providers: { + inference: { + headers: { + "X-NemoClaw-Upstream-Provider": "nvidia-prod", + }, + }, + }, + }); + + await expect(runInferenceSet(options, deps)).resolves.toMatchObject({ + inSandboxConfigSynced: true, + }); + expect(persistedConfig.models).toMatchObject({ + providers: { + inference: { + headers: { + "X-NemoClaw-Upstream-Provider": "compatible-endpoint", + }, + }, + }, + }); + }); + it("reports degraded (not synced) when the in-sandbox hash recompute fails (#3726)", async () => { const config: ConfigObject = { agents: { defaults: { model: { primary: "inference/moonshotai/kimi-k2.6" } } }, diff --git a/src/lib/actions/inference-set-openclaw-gateway-restart.test.ts b/src/lib/actions/inference-set-openclaw-gateway-restart.test.ts index 408ed0e4957..7090f1d9be3 100644 --- a/src/lib/actions/inference-set-openclaw-gateway-restart.test.ts +++ b/src/lib/actions/inference-set-openclaw-gateway-restart.test.ts @@ -58,6 +58,9 @@ describe("runInferenceSet OpenClaw gateway restart", () => { baseUrl: "https://inference.local", apiKey: "unused", api: "anthropic-messages", + headers: { + "X-NemoClaw-Upstream-Provider": "compatible-anthropic-endpoint", + }, models: [ { id: "claude-sonnet-proxy", @@ -121,6 +124,9 @@ describe("runInferenceSet OpenClaw gateway restart", () => { baseUrl: "https://inference.local/v1", apiKey: "unused", api: "openai-completions", + headers: { + "X-NemoClaw-Upstream-Provider": "nvidia-prod", + }, models: [{ id: "nvidia/model-a", name: "inference/nvidia/model-a" }], }, }, diff --git a/src/lib/actions/inference-set-openclaw-run.test.ts b/src/lib/actions/inference-set-openclaw-run.test.ts index b63c7d54d3c..e18890f8b1f 100644 --- a/src/lib/actions/inference-set-openclaw-run.test.ts +++ b/src/lib/actions/inference-set-openclaw-run.test.ts @@ -166,4 +166,60 @@ describe("runInferenceSet OpenClaw routing", () => { primaryModelRef: "inference/anthropic.claude-sonnet-4-6-20260101-v1:0", }); }); + + it("replaces a prior runtime provider marker when switching back to NVIDIA Build", async () => { + const config: ConfigObject = { + agents: { defaults: { model: { primary: "inference/openai/gpt-5.4-mini" } } }, + models: { + providers: { + inference: { + baseUrl: "https://inference.local/v1", + api: "openai-completions", + headers: { + "X-NemoClaw-Upstream-Provider": "compatible-endpoint", + }, + models: [{ id: "openai/gpt-5.4-mini", name: "inference/openai/gpt-5.4-mini" }], + }, + }, + }, + }; + const deps = createDeps({ + config, + entry: { + name: "alpha", + agent: "openclaw", + provider: "compatible-endpoint", + model: "openai/gpt-5.4-mini", + endpointUrl: "https://compatible.example/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + }, + session: baseSession({ + provider: "compatible-endpoint", + model: "openai/gpt-5.4-mini", + endpointUrl: "https://compatible.example/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + }), + }); + + await runInferenceSet( + { + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + noVerify: true, + }, + deps, + ); + + expect(config.models).toMatchObject({ + providers: { + inference: { + headers: { + "X-NemoClaw-Upstream-Provider": "nvidia-prod", + }, + }, + }, + }); + }); }); diff --git a/src/lib/actions/inference-set-patch-openclaw.test.ts b/src/lib/actions/inference-set-patch-openclaw.test.ts index a69d423a913..8bf16be532d 100644 --- a/src/lib/actions/inference-set-patch-openclaw.test.ts +++ b/src/lib/actions/inference-set-patch-openclaw.test.ts @@ -179,6 +179,43 @@ describe("patchOpenClawInferenceConfig", () => { expect(result.changed).toBe(false); }); + it("records a provider switch in a request marker without replacing other headers", () => { + const config: ConfigObject = { + agents: { defaults: { model: { primary: "inference/nvidia/old-model" } } }, + models: { + providers: { + inference: { + baseUrl: "https://inference.local/v1", + apiKey: "unused", + api: "openai-completions", + headers: { + "X-Existing": "keep", + "x-nemoclaw-upstream-provider": "nvidia-prod", + }, + models: [{ id: "nvidia/old-model", name: "inference/nvidia/old-model" }], + }, + }, + }, + }; + + patchOpenClawInferenceConfig( + config, + "compatible-endpoint", + "nvidia/nemotron-3-super-120b-a12b", + "openai-completions", + undefined, + "compatible-endpoint", + ); + + const models = config.models as ConfigObject; + const providers = models.providers as ConfigObject; + const inference = providers.inference as ConfigObject; + expect(inference.headers).toEqual({ + "X-Existing": "keep", + "X-NemoClaw-Upstream-Provider": "compatible-endpoint", + }); + }); + it("seeds new Anthropic routes with the required default reply budget", () => { const config: ConfigObject = { agents: {}, models: { providers: {} } }; diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index e5b76a1c408..86f70df3def 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -349,6 +349,26 @@ function cloneConfigObject(value: ConfigValue | undefined): ConfigObject { return { ...value }; } +const OPENCLAW_UPSTREAM_PROVIDER_HEADER = "X-NemoClaw-Upstream-Provider"; + +// The image environment records the onboarding provider, but inference-set can +// switch the live route without rebuilding. Carry the current non-secret +// provider identity with OpenClaw's runtime config; the sandbox preload removes +// this private marker before forwarding the request. +function withOpenClawUpstreamProviderHeader( + existing: ConfigObject, + upstreamProvider: string, +): ConfigObject { + const headers = cloneConfigObject(existing.headers); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === OPENCLAW_UPSTREAM_PROVIDER_HEADER.toLowerCase()) { + delete headers[key]; + } + } + headers[OPENCLAW_UPSTREAM_PROVIDER_HEADER] = upstreamProvider; + return { ...existing, headers }; +} + function asConfigObject(value: Record): ConfigObject { const result: ConfigObject = {}; for (const [key, entry] of Object.entries(value)) { @@ -392,6 +412,7 @@ function buildProviderConfig( route: SandboxInferenceConfig, contextWindow?: number, inheritedMaxTokens?: number, + upstreamProviderMarker?: string, ): ConfigObject { const firstExistingModel = Array.isArray(existing.models) ? cloneConfigObject(existing.models[0]) @@ -411,13 +432,16 @@ function buildProviderConfig( firstExistingModel.compat = asConfigObject(route.inferenceCompat); } - return { + const providerConfig: ConfigObject = { ...existing, baseUrl: route.inferenceBaseUrl, apiKey: typeof existing.apiKey === "string" && existing.apiKey ? existing.apiKey : "unused", api: route.inferenceApi, models: [firstExistingModel], }; + return upstreamProviderMarker + ? withOpenClawUpstreamProviderHeader(providerConfig, upstreamProviderMarker) + : providerConfig; } export function patchOpenClawInferenceConfig( @@ -426,6 +450,7 @@ export function patchOpenClawInferenceConfig( model: string, preferredInferenceApi: string | null = null, contextWindow?: number, + upstreamProviderMarker?: string, ): { changed: boolean; route: SandboxInferenceConfig } { const before = JSON.stringify(config); const route = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -443,6 +468,7 @@ export function patchOpenClawInferenceConfig( route, contextWindow, inheritedMaxTokens, + upstreamProviderMarker, ); return { changed: before !== JSON.stringify(config), route }; @@ -881,6 +907,7 @@ async function runInferenceSetWithoutHostLock( model, preferredInferenceApi || getPreferredInferenceApi(config), contextWindow ?? undefined, + provider, ); } diff --git a/test/nemotron-inference-fix.test.ts b/test/nemotron-inference-fix.test.ts index 9da19131a54..8f2a22ecb40 100644 --- a/test/nemotron-inference-fix.test.ts +++ b/test/nemotron-inference-fix.test.ts @@ -171,7 +171,7 @@ console.log(JSON.stringify(records)); expect(JSON.parse(records[6].writes[0]).chat_template_kwargs).toBeUndefined(); }); - it("preload strips top-level `thinking` only for the exact Ultra model ID (#6913)", () => { + it("preload strips top-level `thinking` for Nemotron-3 only on managed NVIDIA Build routes (#6913)", () => { const preload = extractStartScriptHeredoc(src, "NEMOTRON_FIX_EOF"); const harness = ` const http = require('http'); @@ -203,14 +203,23 @@ function send(mod, options, body) { } // A system message is present so the #4851 tool-less nudge does not fire and // the assertions stay focused on the top-level thinking strip. -send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-ultra-550b-a55b', messages: [{ role: 'system', content: 'x' }], thinking: { type: 'enabled' } })); -send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-ultra-550b-a55b', messages: [{ role: 'system', content: 'x' }], thinking: true })); -send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-ultra-550b-a55b', messages: [{ role: 'system', content: 'x' }] })); -send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-ultra-550b-a55b-other', messages: [{ role: 'system', content: 'x' }], thinking: { type: 'enabled' } })); -send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-super-120b-a12b', messages: [{ role: 'system', content: 'x' }], thinking: { type: 'enabled' } })); -send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-nano-30b-a3b', messages: [{ role: 'system', content: 'x' }], thinking: true })); -send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'deepseek-ai/deepseek-v4-pro', messages: [], thinking: { type: 'enabled' } })); -send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'openai/gpt-oss-120b', messages: [], thinking: { type: 'enabled' } })); +process.env.NEMOCLAW_UPSTREAM_PROVIDER = 'nvidia-prod'; +send(http, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-ultra-550b-a55b', messages: [{ role: 'system', content: 'x' }], thinking: { type: 'enabled' } })); +send(https, { method: 'POST', hostname: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-ultra-550b-a55b', messages: [{ role: 'system', content: 'x' }], thinking: true })); +send(https, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-ultra-550b-a55b', messages: [{ role: 'system', content: 'x' }] })); +send(https, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-4-ultra-550b-a55b', messages: [{ role: 'system', content: 'x' }], thinking: { type: 'enabled' } })); +send(https, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-super-120b-a12b', messages: [{ role: 'system', content: 'x' }], thinking: { type: 'enabled' } })); +send(https, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-nano-30b-a3b', messages: [{ role: 'system', content: 'x' }], thinking: true })); +send(https, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'deepseek-ai/deepseek-v4-pro', messages: [], thinking: { type: 'enabled' } })); +send(https, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'openai/gpt-oss-120b', messages: [], thinking: { type: 'enabled' } })); +// Runtime provider markers override the stale image-baked provider after +// the inference set command switches the managed route. +send(http, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions', headers: { 'X-NemoClaw-Upstream-Provider': 'compatible-endpoint' } }, JSON.stringify({ model: 'nvidia/nemotron-3-super-120b-a12b', messages: [{ role: 'system', content: 'x' }], thinking: { type: 'enabled' } })); +send(https, { method: 'POST', hostname: 'inference.local', path: '/v1/chat/completions', headers: { 'x-nemoclaw-upstream-provider': 'nim-local' } }, JSON.stringify({ model: 'nvidia/nemotron-3-nano-30b-a3b', messages: [{ role: 'system', content: 'x' }], thinking: true })); +process.env.NEMOCLAW_UPSTREAM_PROVIDER = 'compatible-endpoint'; +send(https, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions', headers: { 'X-NemoClaw-Upstream-Provider': 'nvidia-prod' } }, JSON.stringify({ model: 'nvidia/nemotron-3-super-120b-a12b', messages: [{ role: 'system', content: 'x' }], thinking: { type: 'enabled' } })); +delete process.env.NEMOCLAW_UPSTREAM_PROVIDER; +send(https, { method: 'POST', host: 'inference.local', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nvidia/nemotron-3-super-120b-a12b', messages: [{ role: 'system', content: 'x' }], thinking: false })); console.log(JSON.stringify(records)); `; @@ -240,11 +249,11 @@ console.log(JSON.stringify(records)); const ultraNone = JSON.parse(records[2].writes[0]); expect(ultraNone).toEqual(ultraObj); - // Prefix collisions and other Nemotron-3 IDs remain outside the accepted - // strip scope. Their pre-existing force_nonempty_content rewrite remains. - const prefixCollision = JSON.parse(records[3].writes[0]); - expect(prefixCollision).toEqual({ - model: "nvidia/nemotron-3-ultra-550b-a55b-other", + // Adjacent Nemotron families remain outside the accepted strip scope. Their + // pre-existing force_nonempty_content rewrite remains. + const otherFamily = JSON.parse(records[3].writes[0]); + expect(otherFamily).toEqual({ + model: "nvidia/nemotron-4-ultra-550b-a55b", messages: [{ role: "system", content: "x" }], thinking: { type: "enabled" }, chat_template_kwargs: { force_nonempty_content: true }, @@ -254,7 +263,6 @@ console.log(JSON.stringify(records)); expect(superObj).toEqual({ model: "nvidia/nemotron-3-super-120b-a12b", messages: [{ role: "system", content: "x" }], - thinking: { type: "enabled" }, chat_template_kwargs: { force_nonempty_content: true }, }); @@ -262,7 +270,6 @@ console.log(JSON.stringify(records)); expect(nanoBool).toEqual({ model: "nvidia/nemotron-3-nano-30b-a3b", messages: [{ role: "system", content: "x" }], - thinking: true, chat_template_kwargs: { force_nonempty_content: true }, }); @@ -285,6 +292,21 @@ console.log(JSON.stringify(records)); messages: [], thinking: { type: "enabled" }, }); + + const compatibleEndpoint = JSON.parse(records[8].writes[0]); + expect(compatibleEndpoint.thinking).toEqual({ type: "enabled" }); + expect(records[8].removed).toContain("x-nemoclaw-upstream-provider"); + + const localNim = JSON.parse(records[9].writes[0]); + expect(localNim.thinking).toBe(true); + expect(records[9].removed).toContain("x-nemoclaw-upstream-provider"); + + const switchedToBuild = JSON.parse(records[10].writes[0]); + expect(switchedToBuild.thinking).toBeUndefined(); + expect(records[10].removed).toContain("x-nemoclaw-upstream-provider"); + + const missingProvider = JSON.parse(records[11].writes[0]); + expect(missingProvider.thinking).toBe(false); }); it("preload also injects model-specific kwargs for stubbed fetch requests", () => { @@ -302,6 +324,7 @@ globalThis.fetch = async function (input, init) { }; ${preload} async function main() { + process.env.NEMOCLAW_UPSTREAM_PROVIDER = 'nvidia-prod'; await fetch('https://inference.local/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': '999' }, @@ -310,6 +333,24 @@ async function main() { messages: [{ role: 'user', content: 'hello' }], }), }); + await fetch('https://inference.local/v1/chat/completions', { + method: 'POST', + headers: { 'X-NemoClaw-Upstream-Provider': 'nvidia-prod' }, + body: JSON.stringify({ + model: 'nvidia/nemotron-3-super-120b-a12b', + messages: [{ role: 'system', content: 'x' }], + thinking: true, + }), + }); + await fetch('https://inference.local/v1/chat/completions', { + method: 'POST', + headers: { 'X-NemoClaw-Upstream-Provider': 'compatible-endpoint' }, + body: JSON.stringify({ + model: 'nvidia/nemotron-3-super-120b-a12b', + messages: [{ role: 'system', content: 'x' }], + thinking: true, + }), + }); await fetch('https://inference.local/v1/chat/completions', { method: 'POST', headers: new Headers({ 'content-type': 'application/json', 'content-length': '999' }), @@ -333,6 +374,7 @@ async function main() { record.headers instanceof Headers ? record.headers.get('content-length') : (record.headers && record.headers['content-length']) || null, + upstreamProvider: new Headers(record.headers || {}).get('x-nemoclaw-upstream-provider'), })))); } main().catch((err) => { @@ -349,10 +391,14 @@ main().catch((err) => { const records = JSON.parse(result.stdout.trim()); expect(JSON.parse(records[0].body).chat_template_kwargs).toEqual({ thinking: false }); expect(records[0].contentLength).toBeNull(); - expect(JSON.parse(records[1].body).chat_template_kwargs).toEqual({ thinking: false }); - expect(records[1].contentLength).toBeNull(); - expect(JSON.parse(records[2].body).chat_template_kwargs).toBeUndefined(); - expect(records[2].contentLength).toBe("999"); + expect(JSON.parse(records[1].body).thinking).toBeUndefined(); + expect(records[1].upstreamProvider).toBeNull(); + expect(JSON.parse(records[2].body).thinking).toBe(true); + expect(records[2].upstreamProvider).toBeNull(); + expect(JSON.parse(records[3].body).chat_template_kwargs).toEqual({ thinking: false }); + expect(records[3].contentLength).toBeNull(); + expect(JSON.parse(records[4].body).chat_template_kwargs).toBeUndefined(); + expect(records[4].contentLength).toBe("999"); }); it("preload mutates real Node fetch/undici requests and refreshes Content-Length", () => { @@ -444,11 +490,10 @@ main().catch((err) => { const otherBody = JSON.parse(records[2].body); expect(otherBody.chat_template_kwargs).toBeUndefined(); - // #4851/#6913: Ultra rewrites take the fetch/undici path too: top-level - // thinking is stripped, the system message is prepended, and Content-Length - // is refreshed for the changed body. + // #4851: The tool-less system prompt still takes the fetch/undici path, but + // #6913's Build-specific thinking strip leaves this local endpoint intact. const ultraBody = JSON.parse(records[3].body); - expect("thinking" in ultraBody).toBe(false); + expect(ultraBody.thinking).toEqual({ type: "enabled" }); expect(ultraBody.messages[0].role).toBe("system"); expect(ultraBody.messages[0].content).toMatch(/do not have tools/i); expect(ultraBody.messages[1]).toEqual({