Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions agents/langchain-deepagents-code/generate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
* `<name>:<tag>` separator (e.g. `qwen2.5:7b`, `llama3.1:8b-instruct-q4_0`).
* 2. An `openai:<model>` provider-qualified id — some callers pre-prefix
* the identifier with the deepagents provider label; without a strip
* the emitted default would become `openai:openai:<model>` (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 [
Expand Down Expand Up @@ -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 });
Expand All @@ -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();
92 changes: 92 additions & 0 deletions test/generate-dcode-config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});