From 75e3ac9d4462a070d66e72f28c7f704dbe59c7f6 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Mon, 6 Jul 2026 19:44:24 +0800 Subject: [PATCH 1/2] fix(deepagents): preserve Ollama colon-tag model id in generated config.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agents/langchain-deepagents-code/generate-config.ts::modelNameForOpenAiProvider` assumed `NEMOCLAW_MODEL` might arrive in `:` shape and stripped everything before the first `:` before prepending `openai:` in the generated `~/.deepagents/config.toml`. In practice NemoClaw always passes the bare model id: `src/lib/onboard/providers.ts::getNonInteractiveModel` sets `process.env.NEMOCLAW_MODEL` from the resolved model id, and the `NEMOCLAW_MODEL` build-arg validator explicitly allows `:` in a legal model id. The strip therefore silently ate the leading segment of Ollama tags such as `qwen2.5:7b` — where the `:` is Ollama's `name:tag` separator, not a provider prefix — producing `openai:7b` and `models = ["7b"]` in place of `openai:qwen2.5:7b` and `models = ["qwen2.5:7b"]`. On multi-Ollama-model hosts the truncated tag is ambiguous and could resolve to the wrong model (#6325). Pass the model id through verbatim; the `openai:` provider prefix is prepended by `buildConfig` as before. Add regression tests in `test/generate-dcode-config.test.ts` covering the reporter's exact `qwen2.5:7b` scenario, an additional variant-qualified Ollama tag, non-colonized ids, and a guard that the `openai:` provider prefix appears exactly once. To make `buildConfig` testable, export it and gate the `main()` invocation behind an `isMainModule()` check — same pattern the sibling `agents/hermes/generate-config.ts` already uses. Fixes #6325 Signed-off-by: Yanyun Liao --- .../generate-config.ts | 39 +++++++--- test/generate-dcode-config.test.ts | 74 +++++++++++++++++++ 2 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 test/generate-dcode-config.test.ts diff --git a/agents/langchain-deepagents-code/generate-config.ts b/agents/langchain-deepagents-code/generate-config.ts index 836134bf2fb..490f67e5969 100644 --- a/agents/langchain-deepagents-code/generate-config.ts +++ b/agents/langchain-deepagents-code/generate-config.ts @@ -8,9 +8,10 @@ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; -type Settings = { +export type Settings = { model: string; baseUrl: string; providerKey: string; @@ -86,16 +87,26 @@ function tomlArray(values: readonly string[]): string { return `[${values.map(tomlString).join(", ")}]`; } -function modelNameForOpenAiProvider(model: string): string { - const trimmed = model.trim(); - const providerSeparator = trimmed.indexOf(":"); - if (providerSeparator > 0) { - return trimmed.slice(providerSeparator + 1); - } - return trimmed; +/** + * Return the model identifier NemoClaw should write into the deepagents + * config.toml as the value NemoClaw prepends `openai:` to. + * + * Historical behaviour stripped a leading ":" prefix on the + * theory that `NEMOCLAW_MODEL` might be delivered in `openai:foo` shape. + * In practice NemoClaw always passes the bare model id: see + * `src/lib/onboard/providers.ts::getNonInteractiveModel`, and the + * `NEMOCLAW_MODEL` build-arg validator whose regex explicitly allows `:` + * inside legal model ids. The old strip silently ate the leading segment + * of Ollama tags such as `qwen2.5:7b` — the `:` there is Ollama's + * `name:tag` separator, not a provider prefix — producing `openai:7b` in + * place of `openai:qwen2.5:7b` (#6325). Pass the model through verbatim; + * the `openai:` prefix is prepended by `buildConfig`. + */ +export function modelNameForOpenAiProvider(model: string): string { + return model.trim(); } -function buildConfig(settings: Settings): string { +export function buildConfig(settings: Settings): string { const model = modelNameForOpenAiProvider(settings.model); const defaultModel = `openai:${model}`; return [ @@ -123,7 +134,7 @@ function buildConfig(settings: Settings): string { ].join("\n"); } -function main(): void { +export function main(): void { const settings = readSettings(process.env); const configDir = join(homedir(), ".deepagents"); mkdirSync(join(configDir, ".state"), { recursive: true, mode: 0o770 }); @@ -138,4 +149,8 @@ function main(): void { ); } -main(); +function isMainModule(): boolean { + return process.argv[1] ? import.meta.url === pathToFileURL(resolve(process.argv[1])).href : false; +} + +if (isMainModule()) main(); diff --git a/test/generate-dcode-config.test.ts b/test/generate-dcode-config.test.ts new file mode 100644 index 00000000000..e6b117a5a33 --- /dev/null +++ b/test/generate-dcode-config.test.ts @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + buildConfig, + modelNameForOpenAiProvider, + type Settings, +} from "../agents/langchain-deepagents-code/generate-config.ts"; + +const BASE_SETTINGS: Settings = { + model: "nvidia/nemotron-3-super-120b-a12b", + baseUrl: "https://inference.local/v1", + providerKey: "inference", + upstreamProvider: "nvidia-prod", + inferenceApi: "openai-completions", +}; + +describe("modelNameForOpenAiProvider (#6325)", () => { + it("preserves an Ollama tag containing a colon (`qwen2.5:7b`) verbatim", () => { + // Regression for #6325: the old implementation split on the first `:` + // and returned only the suffix, dropping `qwen2.5` and producing `7b`. + expect(modelNameForOpenAiProvider("qwen2.5:7b")).toBe("qwen2.5:7b"); + }); + + it("preserves an Ollama tag with an additional dash-qualified variant (`llama3.1:8b-instruct-q4_0`)", () => { + expect(modelNameForOpenAiProvider("llama3.1:8b-instruct-q4_0")).toBe( + "llama3.1:8b-instruct-q4_0", + ); + }); + + it("passes non-colonized model ids through unchanged", () => { + expect(modelNameForOpenAiProvider("nvidia/nemotron-3-super-120b-a12b")).toBe( + "nvidia/nemotron-3-super-120b-a12b", + ); + expect(modelNameForOpenAiProvider("gpt-5.4-mini")).toBe("gpt-5.4-mini"); + }); + + it("trims surrounding whitespace but does not otherwise mutate the value", () => { + expect(modelNameForOpenAiProvider(" qwen2.5:7b ")).toBe("qwen2.5:7b"); + }); +}); + +describe("buildConfig for deepagents (#6325)", () => { + it("emits the full Ollama colon-tagged model in both the default and the openai models array", () => { + // Reporter's exact scenario: an Ollama model `qwen2.5:7b`. The generated + // config.toml must keep the full tag on both the `default = "openai:…"` + // line and inside `[models.providers.openai].models = […]`. + const toml = buildConfig({ ...BASE_SETTINGS, model: "qwen2.5:7b" }); + expect(toml).toContain(`default = "openai:qwen2.5:7b"`); + expect(toml).toContain(`models = ["qwen2.5:7b"]`); + expect(toml).not.toContain(`default = "openai:7b"`); + expect(toml).not.toContain(`models = ["7b"]`); + }); + + it("emits an unambiguous default + models entry when the model has no colon", () => { + const toml = buildConfig({ + ...BASE_SETTINGS, + model: "nvidia/nemotron-3-super-120b-a12b", + }); + expect(toml).toContain(`default = "openai:nvidia/nemotron-3-super-120b-a12b"`); + expect(toml).toContain(`models = ["nvidia/nemotron-3-super-120b-a12b"]`); + }); + + it("keeps the openai: provider prefix as the only leading `openai:` token", () => { + // Sanity: the fix must not accidentally double-prefix (e.g. produce + // `openai:openai:qwen2.5:7b`). + const toml = buildConfig({ ...BASE_SETTINGS, model: "qwen2.5:7b" }); + const openaiPrefixes = toml.match(/"openai:/g) ?? []; + // One occurrence in the `[models] default` line, one in the `[models.providers.openai]` + // header is a bare `openai` (no `"openai:` prefix), so the default-line hit is unique. + expect(openaiPrefixes).toHaveLength(1); + }); +}); From 899d22ce877e09b49ff5427f95424fa7464361ad Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Mon, 6 Jul 2026 19:54:25 +0800 Subject: [PATCH 2/2] fix(deepagents): strip only the exact `openai:` provider label, not any colon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI shard `cli-test-shards (1)` caught a regression in the previous fix attempt: the existing `test/langchain-deepagents-code-config.test.ts::does not double-prefix provider-qualified model names` case passes `NEMOCLAW_MODEL=openai:gpt-oss-120b` and expects the emitted default to be `openai:gpt-oss-120b` (no double prefix). Removing the strip entirely produced `openai:openai:gpt-oss-120b` and broke that contract. Narrow the strip to ONLY the exact `openai:` prefix — the sole provider label emitted under `[models.providers.openai]`. Any other colon in the model id is part of the model tag itself (Ollama `:`, HF-style variant qualifiers, etc.) and must pass through. This fixes #6325 (`qwen2.5:7b` → `openai:qwen2.5:7b`) while keeping the existing `openai:gpt-oss-120b` contract intact. Add two more regression tests in `test/generate-dcode-config.test.ts` to lock this in one place: (a) the `openai:` prefix strip stays working, (b) `qwen2.5:7b` and `anthropic:claude-3-sonnet` pass through unchanged. Refs #6325 Signed-off-by: Yanyun Liao --- .../generate-config.ts | 36 +++++++++++++------ test/generate-dcode-config.test.ts | 18 ++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/agents/langchain-deepagents-code/generate-config.ts b/agents/langchain-deepagents-code/generate-config.ts index 490f67e5969..b0c3a55be1d 100644 --- a/agents/langchain-deepagents-code/generate-config.ts +++ b/agents/langchain-deepagents-code/generate-config.ts @@ -91,19 +91,33 @@ function tomlArray(values: readonly string[]): string { * Return the model identifier NemoClaw should write into the deepagents * config.toml as the value NemoClaw prepends `openai:` to. * - * Historical behaviour stripped a leading ":" prefix on the - * theory that `NEMOCLAW_MODEL` might be delivered in `openai:foo` shape. - * In practice NemoClaw always passes the bare model id: see - * `src/lib/onboard/providers.ts::getNonInteractiveModel`, and the - * `NEMOCLAW_MODEL` build-arg validator whose regex explicitly allows `:` - * inside legal model ids. The old strip silently ate the leading segment - * of Ollama tags such as `qwen2.5:7b` — the `:` there is Ollama's - * `name:tag` separator, not a provider prefix — producing `openai:7b` in - * place of `openai:qwen2.5:7b` (#6325). Pass the model through verbatim; - * the `openai:` prefix is prepended by `buildConfig`. + * Two contradictory shapes reach `NEMOCLAW_MODEL` at build time: + * 1. A bare model id — this is what NemoClaw sets non-interactively via + * `src/lib/onboard/providers.ts::getNonInteractiveModel`. The bare id + * may legally contain `:` — the `NEMOCLAW_MODEL` build-arg validator's + * regex explicitly allows `:`, precisely because Ollama tags carry a + * `:` separator (e.g. `qwen2.5:7b`, `llama3.1:8b-instruct-q4_0`). + * 2. An `openai:` provider-qualified id — some callers pre-prefix + * the identifier with the deepagents provider label; without a strip + * the emitted default would become `openai:openai:` (a double + * prefix that the deepagents CLI rejects). See the existing regression + * test `test/langchain-deepagents-code-config.test.ts::does not + * double-prefix provider-qualified model names`. + * + * The previous implementation split on the FIRST `:` and returned the + * suffix, which handled (2) but silently ate the leading segment of any + * (1) that carried a colon — producing `openai:7b` in place of + * `openai:qwen2.5:7b` (#6325). + * + * Strip ONLY the exact `openai:` provider label — the sole provider we + * emit under `[models.providers.openai]`. Any other colon in the model id + * is part of the model tag itself (Ollama's `name:tag` separator, HF-style + * `org/name:tag` variant qualifiers, etc.) and MUST pass through. */ export function modelNameForOpenAiProvider(model: string): string { - return model.trim(); + const trimmed = model.trim(); + const PROVIDER_PREFIX = "openai:"; + return trimmed.startsWith(PROVIDER_PREFIX) ? trimmed.slice(PROVIDER_PREFIX.length) : trimmed; } export function buildConfig(settings: Settings): string { diff --git a/test/generate-dcode-config.test.ts b/test/generate-dcode-config.test.ts index e6b117a5a33..bc5a72d2c8b 100644 --- a/test/generate-dcode-config.test.ts +++ b/test/generate-dcode-config.test.ts @@ -39,6 +39,24 @@ describe("modelNameForOpenAiProvider (#6325)", () => { it("trims surrounding whitespace but does not otherwise mutate the value", () => { expect(modelNameForOpenAiProvider(" qwen2.5:7b ")).toBe("qwen2.5:7b"); }); + + it("strips an already-`openai:`-qualified provider label (existing regression)", () => { + // Pre-existing contract: some callers pre-prefix the model id with the + // deepagents provider label. Without a strip the emitted default would + // become `openai:openai:gpt-oss-120b`, which the deepagents CLI rejects. + expect(modelNameForOpenAiProvider("openai:gpt-oss-120b")).toBe("gpt-oss-120b"); + }); + + it("only strips the exact `openai:` prefix — a colonized model that starts with a different label is unchanged", () => { + // Guard against a hypothetical future regression where a naive + // `indexOf(":")` re-appears: `qwen2.5:7b` starts with `qwen2.5`, not + // `openai`, so nothing may be stripped. + expect(modelNameForOpenAiProvider("qwen2.5:7b")).toBe("qwen2.5:7b"); + // Ditto for other legitimate colon-carrying ids. + expect(modelNameForOpenAiProvider("anthropic:claude-3-sonnet")).toBe( + "anthropic:claude-3-sonnet", + ); + }); }); describe("buildConfig for deepagents (#6325)", () => {