diff --git a/agents/langchain-deepagents-code/generate-config.ts b/agents/langchain-deepagents-code/generate-config.ts index 836134bf2f..b0c3a55be1 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,40 @@ function tomlArray(values: readonly string[]): string { return `[${values.map(tomlString).join(", ")}]`; } -function modelNameForOpenAiProvider(model: string): string { +/** + * Return the model identifier NemoClaw should write into the deepagents + * config.toml as the value NemoClaw prepends `openai:` to. + * + * 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 { const trimmed = model.trim(); - const providerSeparator = trimmed.indexOf(":"); - if (providerSeparator > 0) { - return trimmed.slice(providerSeparator + 1); - } - return trimmed; + const PROVIDER_PREFIX = "openai:"; + return trimmed.startsWith(PROVIDER_PREFIX) ? trimmed.slice(PROVIDER_PREFIX.length) : trimmed; } -function buildConfig(settings: Settings): string { +export function buildConfig(settings: Settings): string { const model = modelNameForOpenAiProvider(settings.model); const defaultModel = `openai:${model}`; return [ @@ -123,7 +148,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 +163,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 0000000000..bc5a72d2c8 --- /dev/null +++ b/test/generate-dcode-config.test.ts @@ -0,0 +1,92 @@ +// 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"); + }); + + 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)", () => { + 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); + }); +});