diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index b9604dcd5b2..d64d2b35fd8 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -57,7 +57,7 @@ RUN chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-dee && cmp -s /usr/local/lib/nemoclaw/dcode-launcher.sh /usr/local/lib/nemoclaw/dcode-managed-exec \ && chmod -R a+rX /opt/nemoclaw-blueprint \ && test "$(find /opt/nemoclaw-deepagents-profile-plugin -type f -print | LC_ALL=C sort)" = "$(printf '%s\n' '/opt/nemoclaw-deepagents-profile-plugin/pyproject.toml' '/opt/nemoclaw-deepagents-profile-plugin/src/nemoclaw_deepagents_profile/__init__.py')" \ - && printf '%s %s\n' '1cee6afafcbe545f5d095c94cb0ad81ff2a1512f84ad9d128a69a9b3d72b3def' '/opt/nemoclaw-deepagents-profile-plugin/src/nemoclaw_deepagents_profile/__init__.py' '7ba7b77bd6f889cc861eddbe3e38fc1f4433a85b7bc2a9b516e19a19a37a7686' '/opt/nemoclaw-deepagents-profile-plugin/pyproject.toml' | sha256sum -c - \ + && printf '%s %s\n' '59f5e458f64964df94a5f95a27b693ffa54d3ded96dc5c865c53d72ba34b64c6' '/opt/nemoclaw-deepagents-profile-plugin/src/nemoclaw_deepagents_profile/__init__.py' '7ba7b77bd6f889cc861eddbe3e38fc1f4433a85b7bc2a9b516e19a19a37a7686' '/opt/nemoclaw-deepagents-profile-plugin/pyproject.toml' | sha256sum -c - \ && /opt/venv/bin/pip3 install --no-index --no-cache-dir --no-deps --no-build-isolation /opt/nemoclaw-deepagents-profile-plugin \ && /opt/venv/bin/python3 -I -c 'import nemoclaw_deepagents_profile; print("NEMOCLAW_DCODE_PROFILE_" + "IMPORT_GATE", flush=True); import deepagents; import deepagents_code' \ && /opt/venv/bin/pip3 check \ @@ -79,6 +79,7 @@ RUN chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-dee ARG NEMOCLAW_MODEL=nvidia/nemotron-3-ultra-550b-a55b ARG NEMOCLAW_PROVIDER_KEY=inference ARG NEMOCLAW_UPSTREAM_PROVIDER=nvidia +ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL= ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_TOOL_DISCLOSURE=progressive @@ -125,6 +126,7 @@ ENV HOME=/sandbox \ NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \ NEMOCLAW_UPSTREAM_PROVIDER=${NEMOCLAW_UPSTREAM_PROVIDER} \ + NEMOCLAW_UPSTREAM_ENDPOINT_URL=${NEMOCLAW_UPSTREAM_ENDPOINT_URL} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} \ diff --git a/agents/langchain-deepagents-code/dependency-review.md b/agents/langchain-deepagents-code/dependency-review.md index 1dca22f7823..aad3533e6d8 100644 --- a/agents/langchain-deepagents-code/dependency-review.md +++ b/agents/langchain-deepagents-code/dependency-review.md @@ -7,9 +7,9 @@ This file records the reviewed dependency baseline for the Deep Agents Code sand Update it whenever `requirements.lock` changes. - Lockfile: `agents/langchain-deepagents-code/requirements.lock` -- Lockfile SHA-256: `d8b01f36a0f325f38d18b4dc2cfdf452125987571a86ca58d9c93e08b7b06a14` +- Lockfile SHA-256: `7889fd275175ceadde843480587a3ed5b3dc517537222e60fa6fdfe4d5b21332` - Audit command: `uv tool run --python 3.13 pip-audit -r agents/langchain-deepagents-code/requirements.lock --progress-spinner off --disable-pip` -- Audit date: 2026-07-07 +- Audit date: 2026-07-09 - Audit result: `No known vulnerabilities found` The Dockerfile installs this lockfile with `pip3 install --require-hashes`, so this review covers the exact package versions selected for the managed image install. @@ -59,7 +59,7 @@ NemoClaw no longer vendors or overlays that source. - Native profile SHA-256: `c8e8dd2b0182334b54be4f46ff0c7b45fbb95dc13bd9a92c249eb47a14fa13d7` - Unmodified built-in bootstrap SHA-256: `005a91e7fc4ca6b21220673dd9d02d6686bf63e1e4f1102d124b01f96886efcf` - First-party adapter: `nemoclaw-deepagents-profile==0.1.0` -- Adapter module SHA-256: `1cee6afafcbe545f5d095c94cb0ad81ff2a1512f84ad9d128a69a9b3d72b3def` +- Adapter module SHA-256: `59f5e458f64964df94a5f95a27b693ffa54d3ded96dc5c865c53d72ba34b64c6` - Adapter project metadata SHA-256: `7ba7b77bd6f889cc861eddbe3e38fc1f4433a85b7bc2a9b516e19a19a37a7686` - Adapter wheel license expression: `Apache-2.0` - Adapter dependency audit result: `No known vulnerabilities found`. Its only @@ -124,12 +124,15 @@ aliases. They are not a new provider profile and do not modify the reviewed canonical NVIDIA profile. The two managed model IDs remain language-local constants in the TypeScript -config generator and the isolated Python image/plugin validators. Those -components run on opposite sides of the offline wheel-install boundary, so a -shared runtime data file would enlarge the installed trust surface solely to -deduplicate two immutable strings. The focused profile-plugin suite extracts -the identifiers from every production consumer and requires the exact sets to -match, preventing drift without adding another mutable build artifact. +config generator and the isolated Python image/plugin validators. NemoClaw +registers both IDs under the managed OpenAI adapter and the managed OpenRouter +adapter because Deep Agents Code applies provider-native request shaping before +it reaches the shared `inference.local` route. Those components run on opposite +sides of the offline wheel-install boundary, so a shared runtime data file would +enlarge the installed trust surface solely to deduplicate two immutable strings. +The focused profile-plugin suite extracts the identifiers from every production +consumer and requires the exact sets to match, preventing drift without adding +another mutable build artifact. For `force_nonempty_content`, the invalid state originates in the NVIDIA Ultra chat template/serving path: a Chat Completions response that combines reasoning diff --git a/agents/langchain-deepagents-code/generate-config.ts b/agents/langchain-deepagents-code/generate-config.ts index 46715343ac7..531dee50b18 100644 --- a/agents/langchain-deepagents-code/generate-config.ts +++ b/agents/langchain-deepagents-code/generate-config.ts @@ -15,14 +15,28 @@ type Settings = { baseUrl: string; providerKey: string; upstreamProvider: string; + upstreamEndpointUrl: string | null; inferenceApi: string; }; +type ManagedDeepAgentsProvider = "openai" | "openrouter"; + +type ManagedDeepAgentsConfig = { + text: string; + provider: ManagedDeepAgentsProvider; + model: string; + defaultModel: string; +}; + const NEMOTRON_ULTRA_MODEL_IDS = new Set([ "nvidia/nemotron-3-ultra-550b-a55b", "nvidia/nvidia/nemotron-3-ultra", ]); +const OPENROUTER_UPSTREAM_PROVIDERS = new Set(["openrouter", "openrouter-api"]); +const OPENROUTER_ENDPOINT_HOST = "openrouter.ai"; +const OPENROUTER_ENDPOINT_PATH = "/api/v1"; + function readSettings(env: NodeJS.ProcessEnv): Settings { const providerKey = normalizeCommentMetadata( env.NEMOCLAW_PROVIDER_KEY || "inference", @@ -38,6 +52,10 @@ function readSettings(env: NodeJS.ProcessEnv): Settings { env.NEMOCLAW_UPSTREAM_PROVIDER || env.NEMOCLAW_PROVIDER_KEY || "inference", "NEMOCLAW_UPSTREAM_PROVIDER", ), + upstreamEndpointUrl: normalizeOptionalEndpointUrl( + env.NEMOCLAW_UPSTREAM_ENDPOINT_URL, + "NEMOCLAW_UPSTREAM_ENDPOINT_URL", + ), inferenceApi: normalizeCommentMetadata( env.NEMOCLAW_INFERENCE_API || "openai-completions", "NEMOCLAW_INFERENCE_API", @@ -58,6 +76,30 @@ function normalizeCommentMetadata(value: string, name: string): string { return value.trim(); } +function normalizeOptionalEndpointUrl(value: string | undefined, name: string): string | null { + if (value === undefined || value.trim() === "") return null; + if (/[\p{Cc}\p{Cf}]/u.test(value)) { + throw new Error(`${name} must not contain control characters.`); + } + const text = value.trim(); + let url: URL; + try { + url = new URL(text); + } catch { + throw new Error(`${name} must be a valid URL.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${name} must use HTTP or HTTPS.`); + } + if (url.username || url.password) { + throw new Error(`${name} must not include credentials.`); + } + if (url.search || url.hash) { + throw new Error(`${name} must not include query strings or fragments.`); + } + return url.href; +} + function normalizeInferenceBaseUrl(value: string): string { if (/[\r\n]/.test(value)) { throw new Error("NEMOCLAW_INFERENCE_BASE_URL must not contain line breaks."); @@ -91,20 +133,42 @@ function tomlArray(values: readonly string[]): string { return `[${values.map(tomlString).join(", ")}]`; } -function modelNameForOpenAiProvider(model: string): string { +function managedDeepAgentsProviderFor(settings: Settings): ManagedDeepAgentsProvider { + if (OPENROUTER_UPSTREAM_PROVIDERS.has(settings.upstreamProvider)) return "openrouter"; + if ( + settings.upstreamProvider === "compatible-endpoint" && + isOpenRouterEndpointUrl(settings.upstreamEndpointUrl) + ) { + return "openrouter"; + } + return "openai"; +} + +function isOpenRouterEndpointUrl(value: string | null): boolean { + if (!value) return false; + const url = new URL(value); + return ( + url.protocol === "https:" && + url.hostname.toLowerCase() === OPENROUTER_ENDPOINT_HOST && + url.pathname.replace(/\/+$/, "") === OPENROUTER_ENDPOINT_PATH + ); +} + +function modelNameForManagedProvider(model: string): string { const trimmed = model.trim(); - return trimmed.startsWith("openai:") ? trimmed.slice("openai:".length) : trimmed; + for (const prefix of ["openai:", "openrouter:"]) { + if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length); + } + return trimmed; } -function buildConfig(settings: Settings): string { - const model = modelNameForOpenAiProvider(settings.model); - const defaultModel = `openai:${model}`; +function openAiModelRequestParamLines(model: string): string[] { // Source boundary: NVIDIA's Ultra serving template owns the empty assistant // content behavior; this generator owns only the managed per-model request // parameters. Keep the exact invalid state, regression proof, and separate // removal conditions for this option and the dispatch guard in // dependency-review.md under "Managed Ultra compatibility workarounds." - const modelParams = NEMOTRON_ULTRA_MODEL_IDS.has(model) + return NEMOTRON_ULTRA_MODEL_IDS.has(model) ? [ "", `[models.providers.openai.params.${tomlString(model)}]`, @@ -112,30 +176,51 @@ function buildConfig(settings: Settings): string { "extra_body = { chat_template_kwargs = { force_nonempty_content = true } }", ] : []; +} + +function providerConfigLines( + provider: ManagedDeepAgentsProvider, + model: string, + baseUrl: string, +): string[] { return [ + `[models.providers.${provider}]`, + `models = ${tomlArray([model])}`, + 'api_key_env = "DEEPAGENTS_CODE_OPENAI_API_KEY"', + `base_url = ${tomlString(baseUrl)}`, + "enabled = true", + ...(provider === "openai" + ? [ + "", + "[models.providers.openai.params]", + "# NemoClaw-managed inference.local currently exposes Chat Completions.", + "# Remove this override when that route supports OpenAI Responses API.", + "use_responses_api = false", + ...openAiModelRequestParamLines(model), + ] + : []), + ]; +} + +function buildConfig(settings: Settings): ManagedDeepAgentsConfig { + const provider = managedDeepAgentsProviderFor(settings); + const model = modelNameForManagedProvider(settings.model); + const defaultModel = `${provider}:${model}`; + const text = [ "# Generated by NemoClaw. This file contains no provider secrets.", `# NemoClaw provider route: ${settings.providerKey}; upstream provider: ${settings.upstreamProvider}; API: ${settings.inferenceApi}.`, "", "[models]", `default = ${tomlString(defaultModel)}`, "", - "[models.providers.openai]", - `models = ${tomlArray([model])}`, - 'api_key_env = "DEEPAGENTS_CODE_OPENAI_API_KEY"', - `base_url = ${tomlString(settings.baseUrl)}`, - "enabled = true", - "", - "[models.providers.openai.params]", - "# NemoClaw-managed inference.local currently exposes Chat Completions.", - "# Remove this override when that route supports OpenAI Responses API.", - "use_responses_api = false", - ...modelParams, + ...providerConfigLines(provider, model, settings.baseUrl), "", "[update]", "check = false", "auto_update = false", "", ].join("\n"); + return { text, provider, model, defaultModel }; } function main(): void { @@ -145,11 +230,12 @@ function main(): void { mkdirSync(join(configDir, "skills"), { recursive: true, mode: 0o770 }); const configPath = join(configDir, "config.toml"); - writeFileSync(configPath, buildConfig(settings)); + const config = buildConfig(settings); + writeFileSync(configPath, config.text); chmodSync(configPath, 0o600); console.log( - `[config] Wrote ${configPath} (model=openai:${modelNameForOpenAiProvider(settings.model)}, base_url=${settings.baseUrl})`, + `[config] Wrote ${configPath} (model=${config.defaultModel}, base_url=${settings.baseUrl})`, ); } diff --git a/agents/langchain-deepagents-code/managed-dcode-runtime.py b/agents/langchain-deepagents-code/managed-dcode-runtime.py index e639c517e5c..33ce036e7a8 100644 --- a/agents/langchain-deepagents-code/managed-dcode-runtime.py +++ b/agents/langchain-deepagents-code/managed-dcode-runtime.py @@ -89,10 +89,13 @@ _MANAGED_FETCH_CA_BUNDLE_FILE = Path( "/etc/openshell-tls/ca-bundle.pem" ) -_MANAGED_ADAPTER_PROVIDER = "openai" +# Keep this managed adapter allow-list in sync with generate-config.ts and the +# patch-managed-deepagents-code.py provider guards injected into Deep Agents Code. +_MANAGED_ADAPTER_PROVIDERS = frozenset({"openai", "openrouter"}) _NVIDIA_DISPLAY_PROVIDER_ALIASES = frozenset( {"nvidia", "nvidia-prod", "nvidia-nim", "nvidia-router"} ) +_OPENROUTER_DISPLAY_PROVIDER_ALIASES = frozenset({"openrouter", "openrouter-api"}) _DISPLAY_PROVIDER_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") # Match the launchers' root-owned, image-baked proxy validator. Its deliberate # RFC 1123 deviation permits underscores only for controlled internal/container @@ -1301,15 +1304,14 @@ def managed_auto_approval_enabled() -> bool: def managed_display_provider(adapter_provider: object) -> str: """Return the provider label to show for the managed inference adapter. - Managed inference always routes through the OpenAI-compatible adapter, so - Deep Agents Code reports the wire provider (`openai`) in the status bar and - the model-identity system prompt. Substitute the onboard-selected upstream - provider so those surfaces match the launch page. Only the managed - ``openai`` adapter is relabeled; every other adapter is returned unchanged. - NVIDIA route aliases share the canonical ``nvidia`` display family. + Managed inference normally routes through the OpenAI-compatible adapter, and + OpenRouter routes through Deep Agents Code's native OpenRouter adapter while + still targeting the managed ``inference.local`` gateway. Substitute the + onboard-selected upstream provider so status surfaces match the launch page. + NVIDIA and OpenRouter aliases share canonical display families. """ adapter = adapter_provider if isinstance(adapter_provider, str) else "" - if adapter != _MANAGED_ADAPTER_PROVIDER: + if adapter not in _MANAGED_ADAPTER_PROVIDERS: return adapter upstream = os.environ.get(_UPSTREAM_PROVIDER_ENV, "") @@ -1317,6 +1319,8 @@ def managed_display_provider(adapter_provider: object) -> str: return adapter if upstream in _NVIDIA_DISPLAY_PROVIDER_ALIASES: return "nvidia" + if upstream in _OPENROUTER_DISPLAY_PROVIDER_ALIASES: + return "openrouter" return upstream diff --git a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py index e3be96a9d8a..bd14026c6b9 100644 --- a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py +++ b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py @@ -498,24 +498,26 @@ def _parse_interpreter_ptc(raw): def _get_provider_kwargs(provider: str, *, model_name: str | None = None) -> dict[str, Any]: - """Return only the NemoClaw-managed OpenAI-compatible constructor contract.""" + """Return only the NemoClaw-managed inference constructor contract.""" del model_name from deepagents_code.model_config import ModelConfig, ModelConfigError from deepagents_code._nemoclaw_managed import managed_inference_base_url - if provider != "openai": + if provider not in {"openai", "openrouter"}: raise ModelConfigError( - "Only the NemoClaw-managed OpenAI-compatible provider is enabled" + "Only NemoClaw-managed inference providers are enabled" ) # Load once so malformed TOML still fails through the upstream config error # path, but do not consume mutable provider classes, credentials, params, or # endpoints from it. ModelConfig.load() - return { + kwargs = { "api_key": "nemoclaw-managed-inference", "base_url": managed_inference_base_url(), - "use_responses_api": False, } + if provider == "openai": + kwargs["use_responses_api"] = False + return kwargs ''' # Source-of-truth boundary: upstream Deep Agents Code 0.1.34 resolves and pins @@ -1077,9 +1079,9 @@ def _nemoclaw_codex_mount(self) -> None: def _nemoclaw_select_with_auth_check(self, model_spec: str, provider: str) -> None: if provider: - if provider != "openai": + if provider not in {"openai", "openrouter"}: self.app.notify( - "Only the NemoClaw-managed OpenAI-compatible provider is enabled.", + "Only NemoClaw-managed inference providers are enabled.", severity="warning", markup=False, ) diff --git a/agents/langchain-deepagents-code/profile-plugin/src/nemoclaw_deepagents_profile/__init__.py b/agents/langchain-deepagents-code/profile-plugin/src/nemoclaw_deepagents_profile/__init__.py index 220e844025c..9151ca84b8f 100644 --- a/agents/langchain-deepagents-code/profile-plugin/src/nemoclaw_deepagents_profile/__init__.py +++ b/agents/langchain-deepagents-code/profile-plugin/src/nemoclaw_deepagents_profile/__init__.py @@ -26,13 +26,15 @@ MANAGED_PROFILE_KEYS = ( "openai:nvidia/nemotron-3-ultra-550b-a55b", "openai:nvidia/nvidia/nemotron-3-ultra", + "openrouter:nvidia/nemotron-3-ultra-550b-a55b", + "openrouter:nvidia/nvidia/nemotron-3-ultra", ) _INVALID_EXECUTE_COMMAND = re.compile(r"\[\s*content\s*\]", re.IGNORECASE) _REGISTRATION_LOCK = threading.Lock() # invalidState: Deep Agents resolves pre-built ChatOpenAI models under `openai:` # keys, while its native Ultra profile is registered under an NVIDIA key. -# sourceBoundary: NemoClaw owns only these two managed inference aliases and one +# sourceBoundary: NemoClaw owns only these managed inference aliases and one # exact malformed-tool-call guard layered onto them; the prompt, tool overrides, # bootstrap, canonical profile, and upstream source remain byte-identical Deep # Agents artifacts. @@ -210,13 +212,14 @@ def _register_aliases( raise _fail("managed aliases are in a partial registration state") try: - first_key, second_key = MANAGED_PROFILE_KEYS + first_key, *alias_keys = MANAGED_PROFILE_KEYS register_profile(first_key, native_profile) register_profile(first_key, overlay) managed_profile = registry.get(first_key) if managed_profile is None or managed_profile is native_profile: raise _fail("managed profile overlay was not applied") - register_profile(second_key, managed_profile) + for alias_key in alias_keys: + register_profile(alias_key, managed_profile) if registry.get(CANONICAL_PROFILE_KEY) is not native_profile: raise _fail("canonical profile changed during managed registration") if not all( diff --git a/agents/langchain-deepagents-code/requirements.in b/agents/langchain-deepagents-code/requirements.in index 398d0dab51c..66500e13523 100644 --- a/agents/langchain-deepagents-code/requirements.in +++ b/agents/langchain-deepagents-code/requirements.in @@ -2,5 +2,5 @@ # SPDX-License-Identifier: Apache-2.0 # uv==0.11.15 -deepagents-code[nvidia]==0.1.34 +deepagents-code[nvidia,openrouter]==0.1.34 nemo-relay[langgraph]==0.4.0 diff --git a/agents/langchain-deepagents-code/requirements.lock b/agents/langchain-deepagents-code/requirements.lock index 42e2dbec6f4..865c493d961 100644 --- a/agents/langchain-deepagents-code/requirements.lock +++ b/agents/langchain-deepagents-code/requirements.lock @@ -928,7 +928,9 @@ h11==0.16.0 \ httpcore==1.0.9 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 - # via httpx + # via + # httpx + # openrouter httptools==0.8.0 \ --hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \ --hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \ @@ -994,6 +996,7 @@ httpx==0.28.1 \ # langsmith # mcp # openai + # openrouter # tavily-python httpx-sse==0.4.3 \ --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ @@ -1124,6 +1127,10 @@ jsonpatch==1.33 \ --hash=sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade \ --hash=sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c # via langchain-core +jsonpath-python==1.1.6 \ + --hash=sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4 \ + --hash=sha256:dded9932b4ec41fb8726e09c83afa4e6be618f938c2db287cc2a81723c639671 + # via openrouter jsonpointer==3.1.1 \ --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \ --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca @@ -1191,6 +1198,7 @@ langchain-core==1.4.8 \ # langchain-mcp-adapters # langchain-nvidia-ai-endpoints # langchain-openai + # langchain-openrouter # langchain-quickjs # langgraph # langgraph-api @@ -1216,6 +1224,10 @@ langchain-openai==1.3.3 \ --hash=sha256:143769bf943820b80db769e47ca8fd0aac08ed18714519333b044c4431e9aa67 \ --hash=sha256:e469659862c8aabba4f6653df973206e7be54f98cf2275c86be7f06b7abe20d7 # via deepagents-code +langchain-openrouter==0.2.6 \ + --hash=sha256:267127e34f921450298ab82fbbef8dd0d4bb2b440040769df0af203ce13048f4 \ + --hash=sha256:dc77927cd70d837adea977b1253cf7cebc3055724c419169ac1321d936281ba8 + # via deepagents-code langchain-protocol==0.0.18 \ --hash=sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a \ --hash=sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6 @@ -1474,6 +1486,10 @@ openai==2.43.0 \ --hash=sha256:65a670b54fadf2268c9e1330133373c963eb779ee969e5cbad419ec2c21dce97 \ --hash=sha256:e74d238200a26868977002190fb6631613480a93dfe0c9c982e77021ed60a017 # via langchain-openai +openrouter==0.10.8 \ + --hash=sha256:ac7f4cea1ec1c2a063b0ad9792a8f64dde68ed053279b6c42bee849a50afc47c \ + --hash=sha256:ff9b3cd2f55a3f905d21f56517344da9f9556ffbd2cb9d31ddc388c4b930aab6 + # via langchain-openrouter opentelemetry-api==1.42.1 \ --hash=sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714 \ --hash=sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716 @@ -1922,6 +1938,7 @@ pydantic==2.13.4 \ # langsmith # mcp # openai + # openrouter # pydantic-settings pydantic-core==2.46.4 \ --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ diff --git a/agents/langchain-deepagents-code/validate-nemotron-ultra-profile.py b/agents/langchain-deepagents-code/validate-nemotron-ultra-profile.py index 6cbbf0934ec..92b934682e6 100644 --- a/agents/langchain-deepagents-code/validate-nemotron-ultra-profile.py +++ b/agents/langchain-deepagents-code/validate-nemotron-ultra-profile.py @@ -47,7 +47,7 @@ ) EXPECTED_PLUGIN_LICENSE_EXPRESSION = "Apache-2.0" EXPECTED_PLUGIN_SOURCE_SHA256 = ( - "1cee6afafcbe545f5d095c94cb0ad81ff2a1512f84ad9d128a69a9b3d72b3def" + "59f5e458f64964df94a5f95a27b693ffa54d3ded96dc5c865c53d72ba34b64c6" ) EXPECTED_NATIVE_PROFILE_SHA256 = ( "c8e8dd2b0182334b54be4f46ff0c7b45fbb95dc13bd9a92c249eb47a14fa13d7" diff --git a/scripts/check-dcode-profile-import-gate.sh b/scripts/check-dcode-profile-import-gate.sh index bef43852ec3..77e5fa294ca 100755 --- a/scripts/check-dcode-profile-import-gate.sh +++ b/scripts/check-dcode-profile-import-gate.sh @@ -37,7 +37,7 @@ for dockerfile in \ agents/langchain-deepagents-code/Dockerfile; do while IFS= read -r arg_name; do case "${arg_name}" in - BASE_IMAGE | NEMOCLAW_MODEL | NEMOCLAW_PROVIDER_KEY | NEMOCLAW_UPSTREAM_PROVIDER | NEMOCLAW_INFERENCE_BASE_URL | NEMOCLAW_INFERENCE_API | NEMOCLAW_TOOL_DISCLOSURE | NEMOCLAW_DCODE_AUTO_APPROVAL | NEMOCLAW_BUILD_ID | NEMOCLAW_DARWIN_VM_COMPAT | NEMOCLAW_PROXY_HOST | NEMOCLAW_PROXY_PORT) ;; + BASE_IMAGE | NEMOCLAW_MODEL | NEMOCLAW_PROVIDER_KEY | NEMOCLAW_UPSTREAM_PROVIDER | NEMOCLAW_UPSTREAM_ENDPOINT_URL | NEMOCLAW_INFERENCE_BASE_URL | NEMOCLAW_INFERENCE_API | NEMOCLAW_TOOL_DISCLOSURE | NEMOCLAW_DCODE_AUTO_APPROVAL | NEMOCLAW_BUILD_ID | NEMOCLAW_DARWIN_VM_COMPAT | NEMOCLAW_PROXY_HOST | NEMOCLAW_PROXY_PORT) ;; *) echo "ERROR: plain-progress build refuses unreviewed ARG ${arg_name} in ${dockerfile}" >&2 exit 1 diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3feb42a69de..c2d1ef88df7 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2779,7 +2779,6 @@ async function createSandboxWithBaseImageResolution( ); } if (sandboxGpuLogMessage) console.log(sandboxGpuLogMessage); - console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const envMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); const plannedMessagingState = @@ -2796,6 +2795,7 @@ async function createSandboxWithBaseImageResolution( model, chatUiUrl, provider, + endpointUrl: createIntent?.endpointUrl ?? null, preferredInferenceApi, webSearchConfig, toolDisclosure: effectiveToolDisclosure, diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index c1107988550..ccd994f3134 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -297,6 +297,168 @@ describe("dockerfile patch helpers", () => { expect(patched).toContain("ARG NEMOCLAW_UPSTREAM_PROVIDER=nvidia-prod"); }); + it("writes the user-selected upstream endpoint into NEMOCLAW_UPSTREAM_ENDPOINT_URL", () => { + const dockerfilePath = dockerfileWith( + [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_UPSTREAM_PROVIDER=old", + "ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "nvidia/nemotron-3-ultra-550b-a55b", + "https://chat.example", + "build-1", + "compatible-endpoint", + null, + null, + null, + false, + null, + [], + { upstreamEndpointUrl: "https://openrouter.ai/api/v1" }, + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + expect(patched).toContain("ARG NEMOCLAW_UPSTREAM_PROVIDER=compatible-endpoint"); + expect(patched).toContain("ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=https://openrouter.ai/api/v1"); + }); + + it("clears a stale upstream endpoint when no upstream endpoint is selected", () => { + const dockerfilePath = dockerfileWith( + [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_UPSTREAM_PROVIDER=old", + "ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=https://stale.example/v1", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "nvidia/nemotron-3-ultra-550b-a55b", + "https://chat.example", + "build-1", + "nvidia-prod", + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + expect(patched).toContain("ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL="); + expect(patched).not.toContain("https://stale.example/v1"); + }); + + it("canonicalizes upstream endpoint URLs before writing Dockerfile ARGs", () => { + const dockerfilePath = dockerfileWith( + [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_UPSTREAM_PROVIDER=old", + "ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "nvidia/nemotron-3-ultra-550b-a55b", + "https://chat.example", + "build-1", + "compatible-endpoint", + null, + null, + null, + false, + null, + [], + { upstreamEndpointUrl: "https://example.test/path\\name" }, + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + expect(patched).toContain("ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=https://example.test/path/name"); + expect(patched).not.toContain("\\"); + }); + + it.each([ + [ + "credentials", + "https://user:pass@example.test/v1", + "NEMOCLAW_UPSTREAM_ENDPOINT_URL must not include credentials.", + "user:pass", + ], + [ + "query string", + "https://example.test/v1?api_key=sk-test-secret", + "NEMOCLAW_UPSTREAM_ENDPOINT_URL must not include query strings or fragments.", + "sk-test-secret", + ], + [ + "control character", + "https://example.test/v1\t[update]", + "NEMOCLAW_UPSTREAM_ENDPOINT_URL must not contain control characters.", + "[update]", + ], + ])("rejects unsafe upstream endpoint URLs with %s before Dockerfile write", (_label, upstreamEndpointUrl, error, leakedValue) => { + const dockerfilePath = dockerfileWith( + [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_UPSTREAM_PROVIDER=old", + "ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + ].join("\n"), + ); + + expect(() => + patchStagedDockerfile( + dockerfilePath, + "nvidia/nemotron-3-ultra-550b-a55b", + "https://chat.example", + "build-1", + "compatible-endpoint", + null, + null, + null, + false, + null, + [], + { upstreamEndpointUrl }, + ), + ).toThrow(error); + + const dockerfile = fs.readFileSync(dockerfilePath, "utf-8"); + expect(dockerfile).toContain("ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=old"); + expect(dockerfile).not.toContain(leakedValue); + }); + it("falls back to the provider key when no upstream provider is supplied", () => { const dockerfilePath = dockerfileWith( [ diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 7a06896668e..c26c8473a92 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -56,6 +56,30 @@ function encodeSanitizedDockerJsonArg(value: unknown): string { return sanitizeDockerArg(encodeDockerJsonArg(value)); } +function normalizeOptionalEndpointUrlArg(value: string | null | undefined, name: string): string { + if (value === null || value === undefined || value.trim() === "") return ""; + if (/[\p{Cc}\p{Cf}]/u.test(value)) { + throw new Error(`${name} must not contain control characters.`); + } + const text = value.trim(); + let url: URL; + try { + url = new URL(text); + } catch { + throw new Error(`${name} must be a valid URL.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${name} must use HTTP or HTTPS.`); + } + if (url.username || url.password) { + throw new Error(`${name} must not include credentials.`); + } + if (url.search || url.hash) { + throw new Error(`${name} must not include query strings or fragments.`); + } + return url.href; +} + export type DockerfileBuildIdPolicy = "preserve" | "rewrite"; export interface PatchStagedDockerfileOptions { @@ -64,6 +88,7 @@ export interface PatchStagedDockerfileOptions { requireToolDisclosureContract?: boolean; baseImageResolutionMetadata?: SandboxBaseImageResolutionMetadata | null; dcodeAutoApprovalMode?: DcodeAutoApprovalMode; + upstreamEndpointUrl?: string | null; } export function patchDcodeAutoApprovalDockerArg( @@ -171,6 +196,14 @@ export function patchStagedDockerfile( /^ARG NEMOCLAW_UPSTREAM_PROVIDER=.*$/m, `ARG NEMOCLAW_UPSTREAM_PROVIDER=${sanitizeDockerArg(upstreamProvider)}`, ); + const upstreamEndpointUrl = normalizeOptionalEndpointUrlArg( + options.upstreamEndpointUrl, + "NEMOCLAW_UPSTREAM_ENDPOINT_URL", + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=.*$/m, + `ARG NEMOCLAW_UPSTREAM_ENDPOINT_URL=${upstreamEndpointUrl}`, + ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, `ARG NEMOCLAW_PRIMARY_MODEL_REF=${sanitizeDockerArg(primaryModelRef)}`, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 58f4bfe3772..25afabaa315 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -608,6 +608,28 @@ class SandboxStateFlow< return state.webSearchConfig; } + private buildSandboxCreateIntent( + state: SandboxStepState, + decision: SandboxCreationDecision, + ): SandboxCreateIntent { + return { + recreate: decision.kind !== "create", + toolDisclosure: toolDisclosureOrDefault(state.session?.toolDisclosure), + observabilityEnabled: state.session?.observabilityEnabled === true, + ...(this.options.endpointUrl ? { endpointUrl: this.options.endpointUrl } : {}), + ...(state.session?.observabilityRequestedExplicitly === true + ? { observabilityRequestedExplicitly: true as const } + : {}), + ...(!this.options.fromDockerfile && + isDcodeAgent((this.options.agent as { name?: string } | null)?.name) + ? { dcodeAutoApprovalMode: this.dcodeAutoApprovalMode } + : {}), + ...(this.options.authoritativePolicyTier + ? { policyTier: this.options.authoritativePolicyTier } + : {}), + }; + } + private async createAndRecordSandbox( state: SandboxStepState, requestedSandboxName: string, @@ -637,6 +659,7 @@ class SandboxStateFlow< current.messagingPlan = messagingPlan; return current; }); + const createIntent = this.buildSandboxCreateIntent(state, decision); const sandboxName = await withSandboxPhaseTrace( requestedSandboxName, this.options.provider, @@ -658,21 +681,7 @@ class SandboxStateFlow< resourceProfile, effectiveHermesToolGateways, this.options.hermesAuthMethod, - { - recreate: decision.kind !== "create", - toolDisclosure: toolDisclosureOrDefault(state.session?.toolDisclosure), - observabilityEnabled: state.session?.observabilityEnabled === true, - ...(state.session?.observabilityRequestedExplicitly === true - ? { observabilityRequestedExplicitly: true as const } - : {}), - ...(!this.options.fromDockerfile && - isDcodeAgent((this.options.agent as { name?: string } | null)?.name) - ? { dcodeAutoApprovalMode: this.dcodeAutoApprovalMode } - : {}), - ...(this.options.authoritativePolicyTier - ? { policyTier: this.options.authoritativePolicyTier } - : {}), - }, + createIntent, ), ); // createSandbox() owns the build fingerprint. In particular, reusing an diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts index 0d8b1afc2d3..d623bbd1dbb 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts @@ -38,6 +38,7 @@ export type PrepareSandboxDockerfilePatchInput = { model: string; chatUiUrl: string; provider: string | null; + endpointUrl?: string | null; preferredInferenceApi: string | null; webSearchConfig: WebSearchConfig | null; toolDisclosure?: ToolDisclosure; @@ -103,6 +104,7 @@ export async function prepareSandboxDockerfilePatch({ model, chatUiUrl, provider, + endpointUrl = null, preferredInferenceApi, webSearchConfig, toolDisclosure = DEFAULT_TOOL_DISCLOSURE, @@ -191,6 +193,7 @@ export async function prepareSandboxDockerfilePatch({ return { buildIdPolicy, toolDisclosure, + ...(endpointUrl ? { upstreamEndpointUrl: endpointUrl } : {}), ...(dcodeAutoApprovalMode ? { dcodeAutoApprovalMode } : {}), requireToolDisclosureContract: Boolean(fromDockerfile), ...(metadata ? { baseImageResolutionMetadata: metadata } : {}), diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index 1895c2551d0..0f4068dc943 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -60,6 +60,8 @@ export interface SandboxCreateIntent { /** Present only when the operator explicitly selected observability on or off. */ readonly observabilityRequestedExplicitly?: true; readonly dcodeAutoApprovalMode?: import("./dcode-auto-approval").DcodeAutoApprovalMode; + /** Non-secret upstream endpoint metadata for managed image config generation. */ + readonly endpointUrl?: string | null; /** Internal authoritative rebuild tier used before replacement registration completes. */ readonly policyTier?: string | null; } diff --git a/test/langchain-deepagents-code-config.test.ts b/test/langchain-deepagents-code-config.test.ts index 18c47b98354..d9fe7f503f1 100644 --- a/test/langchain-deepagents-code-config.test.ts +++ b/test/langchain-deepagents-code-config.test.ts @@ -83,6 +83,70 @@ describe("LangChain Deep Agents Code config generator", () => { expect(config).toContain('models = ["gpt-oss-120b"]'); }); + it("uses the native Deep Agents OpenRouter provider for OpenRouter routes (#6549)", () => { + const config = runGenerator({ + NEMOCLAW_MODEL: "nvidia/nemotron-3-ultra-550b-a55b", + NEMOCLAW_UPSTREAM_PROVIDER: "openrouter-api", + }); + + expect(config).toContain('default = "openrouter:nvidia/nemotron-3-ultra-550b-a55b"'); + expect(config).toContain("[models.providers.openrouter]"); + expect(config).toContain('models = ["nvidia/nemotron-3-ultra-550b-a55b"]'); + expect(config).toContain('api_key_env = "DEEPAGENTS_CODE_OPENAI_API_KEY"'); + expect(config).toContain('base_url = "https://inference.local/v1"'); + expect(config).toContain( + "# NemoClaw provider route: inference; upstream provider: openrouter-api; API: openai-completions.", + ); + expect(config).not.toContain("[models.providers.openai]"); + expect(config).not.toContain("use_responses_api"); + expect(config).not.toContain("force_nonempty_content"); + }); + + it("uses the native OpenRouter provider for compatible-endpoint OpenRouter routes (#6549)", () => { + const config = runGenerator({ + NEMOCLAW_MODEL: "nvidia/nemotron-3-ultra-550b-a55b", + NEMOCLAW_UPSTREAM_PROVIDER: "compatible-endpoint", + NEMOCLAW_UPSTREAM_ENDPOINT_URL: "https://openrouter.ai/api/v1", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + }); + + expect(config).toContain('default = "openrouter:nvidia/nemotron-3-ultra-550b-a55b"'); + expect(config).toContain("[models.providers.openrouter]"); + expect(config).toContain('api_key_env = "DEEPAGENTS_CODE_OPENAI_API_KEY"'); + expect(config).toContain('base_url = "https://inference.local/v1"'); + expect(config).toContain( + "# NemoClaw provider route: inference; upstream provider: compatible-endpoint; API: openai-completions.", + ); + expect(config).not.toContain("[models.providers.openai]"); + expect(config).not.toContain("use_responses_api"); + expect(config).not.toContain("force_nonempty_content"); + }); + + it("keeps ordinary compatible-endpoint routes on the OpenAI-compatible provider", () => { + const config = runGenerator({ + NEMOCLAW_UPSTREAM_PROVIDER: "compatible-endpoint", + NEMOCLAW_UPSTREAM_ENDPOINT_URL: "https://example.test/v1", + }); + + expect(config).toContain('default = "openai:nvidia/nemotron-3-super-120b-a12b"'); + expect(config).toContain("[models.providers.openai]"); + expect(config).not.toContain("[models.providers.openrouter]"); + }); + + it("rejects upstream endpoint URLs with control characters before writing config", () => { + const result = runGeneratorProcess({ + NEMOCLAW_UPSTREAM_PROVIDER: "compatible-endpoint", + NEMOCLAW_UPSTREAM_ENDPOINT_URL: "https://example.test/v1\t[update]", + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "NEMOCLAW_UPSTREAM_ENDPOINT_URL must not contain control characters.", + ); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("[update]"); + expect(fs.existsSync(path.join(result.home, ".deepagents", "config.toml"))).toBe(false); + }); + it.each([ "nvidia/nemotron-3-ultra-550b-a55b", "nvidia/nvidia/nemotron-3-ultra", diff --git a/test/langchain-deepagents-code-direct-module-patch.test.ts b/test/langchain-deepagents-code-direct-module-patch.test.ts index e0920b033fa..a00728b8052 100644 --- a/test/langchain-deepagents-code-direct-module-patch.test.ts +++ b/test/langchain-deepagents-code-direct-module-patch.test.ts @@ -966,6 +966,9 @@ async def validate(): selector._select_with_auth_check("openai:model", "openai") assert selector.original_selection == ("openai:model", "openai") selector.original_selection = None + selector._select_with_auth_check("openrouter:model", "openrouter") + assert selector.original_selection == ("openrouter:model", "openrouter") + selector.original_selection = None selector._select_with_auth_check("anthropic:model", "anthropic") assert selector.original_selection is None @@ -1050,12 +1053,18 @@ async def validate(): "base_url": "https://inference.local/v1", "use_responses_api": False, } + openrouter_kwargs = config._get_provider_kwargs("openrouter") + assert openrouter_kwargs == { + "api_key": "nemoclaw-managed-inference", + "base_url": "https://inference.local/v1", + } + assert "use_responses_api" not in openrouter_kwargs model_config.ModelConfig.base_url = "https://attacker.example/v1" assert config._get_provider_kwargs("openai")["base_url"] == "https://inference.local/v1" try: config._get_provider_kwargs("anthropic") except model_config.ModelConfigError as exc: - assert "managed OpenAI-compatible provider" in str(exc) + assert "managed inference providers" in str(exc) else: raise AssertionError("non-managed model provider was allowed") os.environ["LANGGRAPH_CLI_NO_ANALYTICS"] = "0" diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index c2d6ee0fc1b..b1d996fe04f 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -821,13 +821,13 @@ describe("LangChain Deep Agents Code image contracts", () => { const review = readAgentFile("dependency-review.md"); expect(review).toContain("requirements.lock"); - expect(review).toContain("d8b01f36a0f325f38d18b4dc2cfdf452125987571a86ca58d9c93e08b7b06a14"); - expect(review).toContain("Audit date: 2026-07-07"); + expect(review).toContain("7889fd275175ceadde843480587a3ed5b3dc517537222e60fa6fdfe4d5b21332"); + expect(review).toContain("Audit date: 2026-07-09"); expect(review).toContain( "uv tool run --python 3.13 pip-audit -r agents/langchain-deepagents-code/requirements.lock --progress-spinner off --disable-pip", ); expect(review).toContain("No known vulnerabilities found"); - expect(review).toContain("1cee6afafcbe545f5d095c94cb0ad81ff2a1512f84ad9d128a69a9b3d72b3def"); + expect(review).toContain("59f5e458f64964df94a5f95a27b693ffa54d3ded96dc5c865c53d72ba34b64c6"); expect(review).toContain("7ba7b77bd6f889cc861eddbe3e38fc1f4433a85b7bc2a9b516e19a19a37a7686"); expect(review).toContain("Adapter dependency audit result: `No known vulnerabilities found`"); expect(review).toContain("Deep Agents Code `0.1.34` pins `deepagents==0.7.0a6`"); diff --git a/test/langchain-deepagents-code-nemotron-profile-plugin.test.ts b/test/langchain-deepagents-code-nemotron-profile-plugin.test.ts index 244b1cf31de..a954a9b5ec8 100644 --- a/test/langchain-deepagents-code-nemotron-profile-plugin.test.ts +++ b/test/langchain-deepagents-code-nemotron-profile-plugin.test.ts @@ -41,8 +41,12 @@ const CANONICAL_MODEL_SPEC = "nvidia:nvidia/nemotron-3-ultra-550b-a55b"; const MANAGED_MODEL_ALIASES = [ "openai:nvidia/nemotron-3-ultra-550b-a55b", "openai:nvidia/nvidia/nemotron-3-ultra", + "openrouter:nvidia/nemotron-3-ultra-550b-a55b", + "openrouter:nvidia/nvidia/nemotron-3-ultra", ] as const; -const MANAGED_MODEL_IDS = MANAGED_MODEL_ALIASES.map((alias) => alias.slice("openai:".length)); +const MANAGED_MODEL_IDS = [ + ...new Set(MANAGED_MODEL_ALIASES.map((alias) => alias.replace(/^(?:openai|openrouter):/, ""))), +]; const NATIVE_PROFILE_SOURCE = `"""Focused native Nemotron profile fixture.""" @@ -565,7 +569,10 @@ print(json.dumps({ "aliases": aliases_registered, "aliasesShareManagedProfile": ( all(aliases_registered) - and _HARNESS_PROFILES[aliases[0]] is _HARNESS_PROFILES[aliases[1]] + and all( + _HARNESS_PROFILES[key] is _HARNESS_PROFILES[aliases[0]] + for key in aliases[1:] + ) ), "aliasMiddleware": alias_middleware, "canonicalHasGuard": any( @@ -726,7 +733,7 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( const result = runPlugin(fixture, { registerCalls: 2 }); expect(result.status, result.stderr).toBe(0); - expect(result.probe.aliases).toEqual([true, true]); + expect(result.probe.aliases).toEqual(MANAGED_MODEL_ALIASES.map(() => true)); expect(result.probe.aliasesShareManagedProfile).toBe(true); expect(result.probe.aliasMiddleware).toEqual([ "NativeMiddleware", @@ -745,7 +752,7 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( const result = runPlugin(fixture, { concurrentRegisterCalls: 8 }); expect(result.status, result.stderr).toBe(0); - expect(result.probe.aliases).toEqual([true, true]); + expect(result.probe.aliases).toEqual(MANAGED_MODEL_ALIASES.map(() => true)); expect(result.probe.aliasesShareManagedProfile).toBe(true); expect(result.probe.aliasMiddleware).toEqual([ "NativeMiddleware", @@ -841,7 +848,7 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( expect(result.status).not.toBe(0); expect(result.probe.error).toContain(message); - expect(result.probe.aliases).toEqual([false, false]); + expect(result.probe.aliases).toEqual(MANAGED_MODEL_ALIASES.map(() => false)); expectOfficialSourcesUnchanged(fixture); }); @@ -860,7 +867,7 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( expect(result.probe.error).toContain( "imported deepagents package does not match the reviewed distribution", ); - expect(result.probe.aliases).toEqual([false, false]); + expect(result.probe.aliases).toEqual(MANAGED_MODEL_ALIASES.map(() => false)); expectOfficialSourcesUnchanged(fixture); }); @@ -880,7 +887,7 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( expect(result.status).not.toBe(0); expect(result.probe.error).toMatch(/does not match the reviewed Deep Agents/i); - expect(result.probe.aliases).toEqual([false, false]); + expect(result.probe.aliases).toEqual(MANAGED_MODEL_ALIASES.map(() => false)); expectOfficialSourcesUnchanged(fixture, nativeSource, bootstrapSource); }); @@ -897,7 +904,7 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( expect(result.status).not.toBe(0); expect(result.probe.error).toMatch(/not a trusted regular file/i); - expect(result.probe.aliases).toEqual([false, false]); + expect(result.probe.aliases).toEqual(MANAGED_MODEL_ALIASES.map(() => false)); }); it("rejects a missing canonical profile without creating aliases", () => { @@ -906,7 +913,7 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( expect(result.status).not.toBe(0); expect(result.probe.error).toContain("canonical profile"); - expect(result.probe.aliases).toEqual([false, false]); + expect(result.probe.aliases).toEqual(MANAGED_MODEL_ALIASES.map(() => false)); expect(result.probe.registryKeys).toEqual([]); expectOfficialSourcesUnchanged(fixture); }); @@ -920,7 +927,9 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( expect(result.status).not.toBe(0); expect(result.probe.error).toMatch(/partial|conflict/i); - expect(result.probe.registryKeys).toHaveLength(aliasState === "partial" ? 2 : 3); + expect(result.probe.registryKeys).toHaveLength( + aliasState === "partial" ? 2 : MANAGED_MODEL_ALIASES.length + 1, + ); expectOfficialSourcesUnchanged(fixture); }); @@ -933,7 +942,7 @@ describe("LangChain Deep Agents Code managed Nemotron profile plugin (#6424)", ( expect(result.status).not.toBe(0); expect(result.probe.error).toContain("injected registration failure"); - expect(result.probe.aliases).toEqual([false, false]); + expect(result.probe.aliases).toEqual(MANAGED_MODEL_ALIASES.map(() => false)); expect(result.probe.unrelatedPresent).toBe(true); expect(result.probe.registryKeys).toEqual([CANONICAL_MODEL_SPEC, "openai:gpt-4.1-mini"].sort()); expectOfficialSourcesUnchanged(fixture); diff --git a/test/langchain-deepagents-code-profile-build-gate.test.ts b/test/langchain-deepagents-code-profile-build-gate.test.ts index a1067c52538..ec58bc35cc0 100644 --- a/test/langchain-deepagents-code-profile-build-gate.test.ts +++ b/test/langchain-deepagents-code-profile-build-gate.test.ts @@ -105,6 +105,14 @@ describe("LangChain Deep Agents Code profile build gate", () => { expect(result.calls).not.toContain("--file"); }); + it("accepts NEMOCLAW_UPSTREAM_ENDPOINT_URL as a reviewed source-gate ARG", () => { + const result = runGateWithFakeDocker("expected-failure-with-marker"); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(checkPath, "utf8")).toContain("NEMOCLAW_UPSTREAM_ENDPOINT_URL"); + expect(result.calls).toContain("--file agents/langchain-deepagents-code/Dockerfile"); + }); + it("accepts only the expected production-build failure at the runtime marker", () => { const result = runGateWithFakeDocker("expected-failure-with-marker"); diff --git a/test/langchain-deepagents-code-provider-label.test.ts b/test/langchain-deepagents-code-provider-label.test.ts index 95e3c287134..66182b5361f 100644 --- a/test/langchain-deepagents-code-provider-label.test.ts +++ b/test/langchain-deepagents-code-provider-label.test.ts @@ -111,6 +111,10 @@ for upstream in (None, "", "bad provider!", " nvidia-prod", "nvidia-prod\\n", "x os.environ["NEMOCLAW_UPSTREAM_PROVIDER"] = "openai" assert _nemoclaw_managed.managed_display_provider("openai") == "openai" +os.environ["NEMOCLAW_UPSTREAM_PROVIDER"] = "openrouter-api" +assert _nemoclaw_managed.managed_display_provider("openai") == "openrouter" +assert _nemoclaw_managed.managed_display_provider("openrouter") == "openrouter" + os.environ["NEMOCLAW_UPSTREAM_PROVIDER"] = "compatible-anthropic-endpoint" assert _nemoclaw_managed.managed_display_provider("openai") == "compatible-anthropic-endpoint"