diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index a81bdbb6e9..81fd2046f6 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -87,7 +87,7 @@ "name": "OpenRouter", "status": "tested", "endpoint_type": "OpenAI-compatible", - "notes": "First-class onboarding route for OpenClaw, Hermes, and LangChain Deep Agents Code. NemoClaw registers the `openrouter-api` provider through OpenShell's `openai` profile with `OPENAI_BASE_URL=https://openrouter.ai/api/v1`; host-side validation and catalog probes send the default OpenRouter attribution headers." + "notes": "First-class onboarding route for OpenClaw, Hermes, and LangChain Deep Agents Code. NemoClaw registers the `openrouter-api` provider through OpenShell's `openai` profile with a host runtime adapter URL; host-side validation and runtime traffic send the default OpenRouter attribution headers." }, { "name": "OpenAI", diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index f19b14fe8a..fd399a8747 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -64,7 +64,7 @@ This setting is generated when NemoClaw builds or recreates the sandbox image. | Provider | Status | Endpoint type | Notes | |----------|--------|---------------|-------| | NVIDIA Endpoints | Tested | OpenAI-compatible | Hosted models on integrate.api.nvidia.com | -| OpenRouter | Tested | OpenAI-compatible | First-class onboarding route for OpenClaw, Hermes, and LangChain Deep Agents Code. NemoClaw registers the `openrouter-api` provider through OpenShell's `openai` profile with `OPENAI_BASE_URL=https://openrouter.ai/api/v1`; host-side validation and catalog probes send the default OpenRouter attribution headers. | +| OpenRouter | Tested | OpenAI-compatible | First-class onboarding route for OpenClaw, Hermes, and LangChain Deep Agents Code. NemoClaw registers the `openrouter-api` provider through OpenShell's `openai` profile with a host runtime adapter URL; host-side validation and runtime traffic send the default OpenRouter attribution headers. | | OpenAI | Tested | Native OpenAI-compatible | Uses OpenAI model IDs | | Other OpenAI-compatible endpoint | Tested with limitations | Custom OpenAI-compatible | Custom base-URL adapter for servers that implement OpenAI-compatible `/v1/chat/completions` or `/v1/responses`. Behavior on OpenAI-compatible proxies, gateways, and self-hosted implementations may vary; this row claims the adapter, not the universe of compatible endpoints. | | Anthropic | Tested | Native Anthropic | Uses anthropic-messages | @@ -89,10 +89,13 @@ The managed install/start vLLM entry appears by default on DGX Spark and DGX Sta ### Catalog and Default Model Selection During interactive NVIDIA Endpoints onboarding, NemoClaw loads NVIDIA's public featured model catalog once per onboarding session and reports progress before displaying the model picker. -OpenRouter uses the same catalog-backed picker flow with its own OpenRouter featured model catalog, provider route, and credential check. - +OpenRouter uses the same catalog-backed picker flow and the same featured model list as NVIDIA Endpoints, with its own provider route and credential check. NVIDIA Endpoints excludes NVIDIA-retired or unsafe choices and corrects known catalog lag before displaying the result. -OpenRouter does not inherit the NVIDIA-only retired-model filter, so a model can appear in OpenRouter onboarding after it leaves NVIDIA Endpoints. +For OpenRouter runtime traffic, NemoClaw starts or reuses a local host adapter on port `11437` by default and registers OpenShell with `OPENAI_BASE_URL=http://host.openshell.internal:11437/v1`. +The adapter accepts only the OpenShell `Authorization: Bearer ` value bound during setup, forwards requests to `https://openrouter.ai/api/v1`, and injects the OpenRouter attribution headers `HTTP-Referer` and `X-OpenRouter-Title`. +The adapter does not store the API key. +It exposes a host health endpoint and writes logs to `~/.nemoclaw/openrouter-runtime-adapter.log`. +Set `NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT` before onboarding to use a different host port. If the catalog is unavailable, malformed, or contains no safe model IDs, the wizard warns you and uses the bundled fallback list shown below. Nemotron 3 Super remains the shared default for OpenClaw and Hermes when it is present. @@ -100,13 +103,13 @@ LangChain Deep Agents Code uses Nemotron 3 Ultra as its NVIDIA Endpoints default If an agent's default is unavailable, the first live featured model becomes the interactive default. If you set `NEMOCLAW_MODEL` to a safe custom model ID that is absent from the live catalog, it does not replace the live menu default. -Choose **Other** to use that value as the pre-filled manual entry; NemoClaw validates it against NVIDIA Endpoints before continuing. +Choose **Other** to use that value as the pre-filled manual entry; NemoClaw validates it against the selected provider before continuing. NemoClaw neither displays nor accepts an unsafe `NEMOCLAW_MODEL` value as the manual-entry prefill. | Option | Description | Curated models | |--------|-------------|----------------| | NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_INFERENCE_API_KEY`. | Nemotron 3 Ultra 550B, Nemotron 3 Super 120B, Minimax M3 | -| OpenRouter | Routes OpenClaw, Hermes, and Deep Agents Code through OpenRouter's OpenAI-compatible Chat Completions endpoint. Set `OPENROUTER_API_KEY`; valid keys begin with `sk-or-`. NemoClaw validates manual model entries with OpenRouter and keeps the sandbox on `https://inference.local/v1`. | OpenRouter featured catalog models, or any OpenRouter model ID you enter manually. | +| OpenRouter | Routes OpenClaw, Hermes, and Deep Agents Code through OpenRouter's OpenAI-compatible Chat Completions endpoint. Set `OPENROUTER_API_KEY`; valid keys begin with `sk-or-`. NemoClaw validates manual model entries with OpenRouter and keeps the sandbox on `https://inference.local/v1`. | NVIDIA Endpoints featured catalog models, or any OpenRouter model ID you enter manually. | | OpenAI | Routes to the OpenAI API. Set `OPENAI_API_KEY`. | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.4-pro-2026-03-05` | | Other OpenAI-compatible endpoint | Routes to any server that implements `/v1/chat/completions`. NemoClaw uses `/v1/chat/completions` at runtime by default; set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` for proxies that implement it, such as some llama.cpp builds. The wizard prompts for a base URL and model name. Behavior on OpenAI-compatible proxies, gateways, and self-hosted implementations such as LocalAI or llama.cpp may vary. When you enable Telegram messaging, onboarding also runs a bounded sandbox-side smoke check through `https://inference.local/v1/chat/completions`. Set `COMPATIBLE_API_KEY`. | You provide the model name. | | Anthropic | Routes to the Anthropic Messages API. Set `ANTHROPIC_API_KEY`. | `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` | @@ -130,7 +133,7 @@ Managed provider defaults that do not supply an explicit custom endpoint through NemoClaw accepts `http://host.openshell.internal:` only with an explicit port from `1024` through `65535`; this narrow exception supports NemoClaw's sandbox-to-host inference routes and is not a general private-endpoint bypass. -NVIDIA Endpoints, OpenRouter, and Hermes Provider use independent model catalogs, so a model can remain available through one provider after it leaves another provider's curated list. +NVIDIA Endpoints and OpenRouter share the same curated cloud model picker, while Hermes Provider uses its own catalog. Curated-list updates affect new onboarding choices and do not rewrite existing sandbox configurations. Use [Switch Inference Providers](switch-inference-providers) to move an existing sandbox before its configured model becomes unavailable. @@ -291,7 +294,7 @@ OpenRouter credentials are validated as non-empty `sk-or-` keys. |---|---| | OpenAI | Tries `/responses` first, then `/chat/completions`. | | NVIDIA Endpoints | Validates through `/v1/chat/completions` only; NemoClaw skips the `/v1/responses` probe because NVIDIA Build does not expose `/v1/responses` (returns 404 for every model). | -| OpenRouter | Validates through `/v1/chat/completions` only. NemoClaw skips the `/v1/responses` probe and uses managed Chat Completions for OpenClaw, Hermes, and Deep Agents Code. | +| OpenRouter | Host-side catalog, model, and smoke validation use `/v1/chat/completions` only. NemoClaw skips the `/v1/responses` probe and uses managed Chat Completions for OpenClaw, Hermes, and Deep Agents Code. | | Google Gemini | Validates through Gemini's OpenAI-compatible chat-completions path only; NemoClaw skips the `/v1/responses` probe because Gemini does not support the Responses API. | | Other OpenAI-compatible endpoint | Tries `/v1/responses` first with a tool-calling probe; falls back to `/v1/chat/completions`. Selected runtime API defaults to `/v1/chat/completions`; set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` at runtime when validation succeeds. | | Other Anthropic-compatible endpoint | For agents that support Anthropic Messages, including OpenClaw, tries `/v1/messages` with a non-streaming request, then repeats the request with `stream: true` and validates the SSE event sequence. Set `NEMOCLAW_REASONING=true` to skip the streaming check for reasoning-only endpoints. For Hermes and OpenAI-compatible-only agents, validates `/v1/chat/completions`, the surface used by the managed OpenAI frontend. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index dfd0a166d6..c7b6e24195 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3086,6 +3086,7 @@ Passthrough commands do not consume flags intended for the downstream command as | `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference | | `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama inference | | `NEMOCLAW_OLLAMA_PROXY_PORT` | 11435 | Ollama auth proxy | +| `NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT` | 11437 | Host-side OpenRouter runtime adapter | | `NEMOCLAW_DASHBOARD_BIND` | *unset* (loopback) | Dashboard or API forward bind address. Set to `0.0.0.0` to opt in to remote bind for SSH-deployed hosts. | @@ -3094,7 +3095,8 @@ Passthrough commands do not consume flags intended for the downstream command as If a port value is not a valid integer or falls outside the allowed range, the CLI exits with an error. -`NEMOCLAW_GATEWAY_PORT` also cannot overlap configured service, vLLM, Ollama, or Ollama proxy ports, and cannot use reserved auto-allocation ranges or the default inference/proxy ports `8000`, `11434`, and `11435`. +`NEMOCLAW_GATEWAY_PORT` also cannot overlap configured service, vLLM, Ollama, Ollama proxy, or OpenRouter runtime adapter ports, and cannot use reserved auto-allocation ranges or the default inference/proxy ports `8000`, `11434`, `11435`, and `11437`. +When you select OpenRouter, `NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT` must also be distinct from the gateway, vLLM, Ollama, and Ollama proxy ports. When you run multiple NemoClaw gateways with different `NEMOCLAW_GATEWAY_PORT` values, NemoClaw derives a separate gateway name, state directory, and compatibility container name from the port so one gateway does not tear down another. On non-WSL hosts, `NEMOCLAW_OLLAMA_PORT` and `NEMOCLAW_OLLAMA_PROXY_PORT` must be different. If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another free port before onboarding. diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index bf81f2c6ec..17f9011f5d 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -94,7 +94,7 @@ NemoClaw routes inference through the OpenShell gateway. Each row below is a pro | Provider | Status | Endpoint type | Notes | |----------|--------|---------------|-------| | NVIDIA Endpoints | Tested | OpenAI-compatible | Hosted models on integrate.api.nvidia.com | -| OpenRouter | Tested | OpenAI-compatible | First-class onboarding route for OpenClaw, Hermes, and LangChain Deep Agents Code. NemoClaw registers the `openrouter-api` provider through OpenShell's `openai` profile with `OPENAI_BASE_URL=https://openrouter.ai/api/v1`; host-side validation and catalog probes send the default OpenRouter attribution headers. | +| OpenRouter | Tested | OpenAI-compatible | First-class onboarding route for OpenClaw, Hermes, and LangChain Deep Agents Code. NemoClaw registers the `openrouter-api` provider through OpenShell's `openai` profile with a host runtime adapter URL; host-side validation and runtime traffic send the default OpenRouter attribution headers. | | OpenAI | Tested | Native OpenAI-compatible | Uses OpenAI model IDs | | Other OpenAI-compatible endpoint | Tested with limitations | Custom OpenAI-compatible | Custom base-URL adapter for servers that implement OpenAI-compatible `/v1/chat/completions` or `/v1/responses`. Behavior on OpenAI-compatible proxies, gateways, and self-hosted implementations may vary; this row claims the adapter, not the universe of compatible endpoints. | | Anthropic | Tested | Native Anthropic | Uses anthropic-messages | diff --git a/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.test.ts b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.test.ts new file mode 100644 index 0000000000..52472e6866 --- /dev/null +++ b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.test.ts @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { type RunResult, runUninstallPlan } from "./run-plan"; + +function ok(stdout = ""): RunResult { + return { status: 0, stdout, stderr: "" }; +} + +function notFound(): RunResult { + return { status: 1, stdout: "", stderr: "" }; +} + +const OPENROUTER_RUNTIME_ADAPTER_CMDLINE = + "/usr/bin/node /home/test/NemoClaw/dist/lib/inference/openrouter-runtime-adapter-entry.js\n"; + +type RunStub = (args: readonly string[]) => RunResult | null; + +function psStub(pidStr: string, opts: { exited: Set; cmdline?: string; owner?: string }) { + const pid = Number(pidStr); + const responses = new Map RunResult>([ + [ + ["-p", pidStr, "-o", "pid="].join("\0"), + () => (opts.exited.has(pid) ? notFound() : ok(`${pidStr}\n`)), + ], + [["-p", pidStr, "-o", "user="].join("\0"), () => ok(`${opts.owner ?? "testuser"}\n`)], + [ + ["-p", pidStr, "-o", "args="].join("\0"), + () => ok(opts.cmdline ?? OPENROUTER_RUNTIME_ADAPTER_CMDLINE), + ], + ]); + + return (args: readonly string[]): RunResult | null => { + return responses.get(args.join("\0"))?.() ?? null; + }; +} + +function defaultRun(command: string, args: readonly string[]): RunResult { + switch (command) { + case "lsof": + return ok(""); + default: + switch (args[0]) { + case "-c": + return ok("/fake/bin/tool\n"); + case "-f": + return ok(""); + default: + return ok(); + } + } +} + +function runStub(routes: Record = {}) { + return (command: string, args: readonly string[]): RunResult => { + return routes[command]?.(args) ?? defaultRun(command, args); + }; +} + +function lsofPortStub(ports: string[], portPids: Map) { + return (args: readonly string[]): RunResult => { + const port = args[1] ?? ""; + ports.push(port); + return portPids.get(port) ?? ok(""); + }; +} + +describe("OpenRouter Runtime adapter uninstall cleanup", () => { + it("kills the adapter via the persisted PID file (#5826)", () => { + const logs: string[] = []; + const killed: number[] = []; + const exited = new Set(); + const tmpHome = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-uninstall-test-openrouter-pidfile-"), + ); + const pidFile = path.join(tmpHome, ".nemoclaw", "openrouter-runtime-adapter.pid"); + fs.mkdirSync(path.join(tmpHome, ".nemoclaw"), { recursive: true }); + fs.writeFileSync(pidFile, "44323\n"); + + try { + const stub = psStub("44323", { exited }); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => true, + env: { HOME: tmpHome, LOGNAME: "testuser" } as NodeJS.ProcessEnv, + existsSync: (target) => target === pidFile, + isTty: false, + kill: (pid, _signal) => { + killed.push(pid); + exited.add(pid); + return true; + }, + log: (line) => logs.push(line), + rmSync: vi.fn(), + run: runStub({ ps: stub }), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(killed).toContain(44323); + expect(logs).toContain("Stopped OpenRouter Runtime adapter 44323"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("scans the custom adapter port for orphan adapters (#5826)", () => { + const logs: string[] = []; + const killed: number[] = []; + const exited = new Set(); + const lsofPorts: string[] = []; + const stub = psStub("33334", { exited }); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => true, + env: { + HOME: "/tmp/nemoclaw-uninstall-test-openrouter-custom-port", + LOGNAME: "testuser", + NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT: "12037", + } as NodeJS.ProcessEnv, + existsSync: () => false, + isTty: false, + kill: (pid, _signal) => { + killed.push(pid); + exited.add(pid); + return true; + }, + log: (line) => logs.push(line), + rmSync: vi.fn(), + run: runStub({ + lsof: lsofPortStub(lsofPorts, new Map([[":12037", ok("33334\n")]])), + ps: stub, + }), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(lsofPorts).toContain(":12037"); + expect(lsofPorts).not.toContain(":11437"); + expect(killed).toContain(33334); + expect(logs).toContain("Stopped OpenRouter Runtime adapter 33334"); + }); + + it("never kills a process on the adapter port whose cmdline does not match (#5826)", () => { + const logs: string[] = []; + const killed: number[] = []; + const stub = psStub("99998", { + exited: new Set(), + cmdline: "/usr/sbin/nginx -g daemon off;\n", + }); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => true, + env: { + HOME: "/tmp/nemoclaw-uninstall-test-openrouter-foreign", + LOGNAME: "testuser", + } as NodeJS.ProcessEnv, + existsSync: () => false, + isTty: false, + kill: (pid) => { + killed.push(pid); + return true; + }, + log: (line) => logs.push(line), + rmSync: vi.fn(), + run: runStub({ + lsof: lsofPortStub([], new Map([[":11437", ok("99998\n")]])), + ps: stub, + }), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(killed).not.toContain(99998); + expect(logs).toContain("No OpenRouter Runtime adapter processes found"); + }); +}); diff --git a/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts new file mode 100644 index 0000000000..53d4beb025 --- /dev/null +++ b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SpawnSyncOptions } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { sleepMs } from "../../core/wait"; +import type { UninstallPaths } from "../../domain/uninstall/paths"; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +interface OpenRouterRuntimeAdapterCleanupRuntime { + commandExists: (command: string) => boolean; + env: NodeJS.ProcessEnv; + existsSync: (target: string) => boolean; + kill: (pid: number, signal?: NodeJS.Signals | number) => boolean; + log: (message: string) => void; + run: (command: string, args: string[], options?: SpawnSyncOptions) => RunResult; + warn: (message: string) => void; +} + +const OPENROUTER_RUNTIME_ADAPTER_CMDLINE_MARK = "openrouter-runtime-adapter"; +const DEFAULT_OPENROUTER_RUNTIME_ADAPTER_PORT = 11437; + +function splitNonEmptyLines(output: string): string[] { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function resolveOpenRouterRuntimeAdapterPort( + runtime: OpenRouterRuntimeAdapterCleanupRuntime, +): number { + const raw = runtime.env.NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT; + if (raw === undefined || raw === "") return DEFAULT_OPENROUTER_RUNTIME_ADAPTER_PORT; + const trimmed = String(raw).trim(); + if (!/^\d+$/.test(trimmed)) return DEFAULT_OPENROUTER_RUNTIME_ADAPTER_PORT; + const parsed = Number(trimmed); + if (parsed < 1024 || parsed > 65535) return DEFAULT_OPENROUTER_RUNTIME_ADAPTER_PORT; + return parsed; +} + +function isOpenRouterRuntimeAdapterPid( + pid: number, + runtime: OpenRouterRuntimeAdapterCleanupRuntime, +): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + const result = runtime.run("ps", ["-p", String(pid), "-o", "args="], { env: runtime.env }); + return result.status === 0 && result.stdout.includes(OPENROUTER_RUNTIME_ADAPTER_CMDLINE_MARK); +} + +function pidExists(pid: number, runtime: OpenRouterRuntimeAdapterCleanupRuntime): boolean { + return runtime.run("ps", ["-p", String(pid), "-o", "pid="], { env: runtime.env }).status === 0; +} + +function waitForPidExit( + pid: number, + runtime: OpenRouterRuntimeAdapterCleanupRuntime, + timeoutMs: number, +): boolean { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!pidExists(pid, runtime)) return true; + sleepMs(50); + } + return !pidExists(pid, runtime); +} + +function pidOwnedByCurrentUser( + pid: number, + runtime: OpenRouterRuntimeAdapterCleanupRuntime, +): boolean { + const expected = runtime.env.SUDO_USER || runtime.env.LOGNAME || os.userInfo().username; + if (!expected) return true; + const result = runtime.run("ps", ["-p", String(pid), "-o", "user="], { env: runtime.env }); + return result.status === 0 && result.stdout.trim() === expected; +} + +function tryStopOpenRouterRuntimeAdapterPid( + pid: number, + runtime: OpenRouterRuntimeAdapterCleanupRuntime, +): boolean { + runtime.kill(pid); + if (waitForPidExit(pid, runtime, 1000)) { + runtime.log(`Stopped OpenRouter Runtime adapter ${pid}`); + return true; + } + runtime.kill(pid, "SIGKILL"); + if (waitForPidExit(pid, runtime, 1000)) { + runtime.log(`Stopped OpenRouter Runtime adapter ${pid}`); + return true; + } + runtime.warn(`Failed to stop OpenRouter Runtime adapter ${pid}`); + return false; +} + +export function stopOpenRouterRuntimeAdapter( + paths: Pick, + runtime: OpenRouterRuntimeAdapterCleanupRuntime, +): void { + const stopped = new Set(); + + const pidFile = path.join(paths.nemoclawStateDir, "openrouter-runtime-adapter.pid"); + if (runtime.existsSync(pidFile)) { + try { + const raw = fs.readFileSync(pidFile, "utf-8").trim(); + const pid = Number.parseInt(raw, 10); + if (Number.isFinite(pid) && pid > 0 && isOpenRouterRuntimeAdapterPid(pid, runtime)) { + if (tryStopOpenRouterRuntimeAdapterPid(pid, runtime)) stopped.add(pid); + } + } catch { + /* ignore - the State step deletes the file shortly anyway */ + } + } + + if (!runtime.commandExists("lsof")) { + if (stopped.size === 0) { + runtime.warn("lsof not found; skipping orphan OpenRouter Runtime adapter scan."); + } + return; + } + + const adapterPort = resolveOpenRouterRuntimeAdapterPort(runtime); + const lsof = runtime.run("lsof", ["-ti", `:${adapterPort}`], { env: runtime.env }); + const pids = splitNonEmptyLines(lsof.stdout).map(Number).filter(Number.isFinite); + for (const pid of pids) { + if (stopped.has(pid)) continue; + if (!pidOwnedByCurrentUser(pid, runtime)) continue; + if (!isOpenRouterRuntimeAdapterPid(pid, runtime)) continue; + if (tryStopOpenRouterRuntimeAdapterPid(pid, runtime)) stopped.add(pid); + } + + if (stopped.size === 0) runtime.log("No OpenRouter Runtime adapter processes found"); +} diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 484f955ff2..bc9885e7dc 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -983,6 +983,10 @@ describe("uninstall run plan", () => { fs.writeFileSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"), "hello"); fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]"); fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); + fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.pid"), "1235"); + fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.json"), "{}"); + fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.lock"), "lock"); + fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.log"), "{}\n"); fs.mkdirSync(path.join(stateDir, "source")); return { tmpHome, stateDir }; } @@ -1044,6 +1048,10 @@ describe("uninstall run plan", () => { expect(result.exitCode).toBe(0); expectPreservedEntries(stateDir); expect(fs.existsSync(path.join(stateDir, "ollama-auth-proxy.pid"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.pid"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.json"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.lock"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.log"))).toBe(false); expect(fs.existsSync(path.join(stateDir, "source"))).toBe(false); expect(logs).toContain( `Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`, diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 1a6b1e1a2e..282b60394a 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -26,6 +26,7 @@ import { buildUninstallPlan, type UninstallPlan } from "../../domain/uninstall/p import { stopHostGatewayProcesses } from "../../onboard/host-gateway-process"; import { isModelRouterCommandLineForPort } from "../../onboard/model-router-process"; import { stopStaleDashboardListeners } from "../../onboard/stale-gateway-cleanup"; +import { stopOpenRouterRuntimeAdapter } from "./openrouter-runtime-adapter-cleanup"; import { classifyShimPath, type FileSystemDeps } from "./plan"; export interface RunResult { @@ -920,6 +921,7 @@ function executePlan( { logNoProcesses: true }, ); stopOllamaAuthProxy(paths, runtime); + stopOpenRouterRuntimeAdapter(paths, runtime); stopModelRouter(paths, runtime); } else if (step.name === "OpenShell resources") { removeOpenShellResources(options, runtime); diff --git a/src/lib/core/ports.test.ts b/src/lib/core/ports.test.ts index bcc4507f95..024f4929f5 100644 --- a/src/lib/core/ports.test.ts +++ b/src/lib/core/ports.test.ts @@ -3,16 +3,18 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; // Import source directly so tests cannot pass against a stale build. -import { parseGatewayPort, parsePort } from "./ports"; +import { parseGatewayPort, parsePort, validateOpenRouterRuntimeAdapterPort } from "./ports"; const GATEWAY_VALIDATION_OPTIONS = { dashboardPort: 18789, dashboardRangeStart: 18789, dashboardRangeEnd: 18799, + gatewayPort: 8080, vllmPort: 8000, ollamaPort: 11434, ollamaProxyPort: 11435, bedrockRuntimeAdapterPort: 11436, + openrouterRuntimeAdapterPort: 11437, }; describe("parsePort", () => { @@ -114,6 +116,7 @@ describe("parseGatewayPort", () => { ["11434", "Ollama inference"], ["11435", "Ollama auth proxy"], ["11436", "Bedrock Runtime adapter"], + ["11437", "OpenRouter Runtime adapter"], ])("rejects overlap with default port %s", (port, label) => { process.env[ENV_KEY] = port; expect(() => parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toThrow(label); @@ -128,4 +131,46 @@ describe("parseGatewayPort", () => { }), ).toThrow("NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT"); }); + + it("rejects overlap with a configured OpenRouter Runtime adapter port", () => { + process.env[ENV_KEY] = "19003"; + expect(() => + parseGatewayPort(ENV_KEY, 8080, { + ...GATEWAY_VALIDATION_OPTIONS, + openrouterRuntimeAdapterPort: 19003, + }), + ).toThrow("NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT"); + }); +}); + +describe("validateOpenRouterRuntimeAdapterPort", () => { + const ENV_KEY = "NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT"; + + it("allows the default OpenRouter Runtime adapter port", () => { + expect(() => + validateOpenRouterRuntimeAdapterPort(ENV_KEY, 11437, GATEWAY_VALIDATION_OPTIONS), + ).not.toThrow(); + }); + + it.each([ + [8080, "NEMOCLAW_GATEWAY_PORT"], + [8000, "vLLM / NIM inference"], + [11434, "Ollama inference"], + [11435, "Ollama auth proxy"], + [11436, "Bedrock Runtime adapter"], + [18790, "18789-18799"], + ])("rejects OpenRouter adapter overlap with %s", (port, expectedMessage) => { + expect(() => + validateOpenRouterRuntimeAdapterPort(ENV_KEY, port, GATEWAY_VALIDATION_OPTIONS), + ).toThrow(expectedMessage); + }); + + it("rejects OpenRouter adapter overlap with configured service ports", () => { + expect(() => + validateOpenRouterRuntimeAdapterPort(ENV_KEY, 19001, { + ...GATEWAY_VALIDATION_OPTIONS, + vllmPort: 19001, + }), + ).toThrow("NEMOCLAW_VLLM_PORT"); + }); }); diff --git a/src/lib/core/ports.ts b/src/lib/core/ports.ts index cdcf05a897..7cd9ff0da5 100644 --- a/src/lib/core/ports.ts +++ b/src/lib/core/ports.ts @@ -32,6 +32,11 @@ export interface GatewayPortValidationOptions { ollamaPort: number; ollamaProxyPort: number; bedrockRuntimeAdapterPort: number; + openrouterRuntimeAdapterPort: number; +} + +export interface RuntimeAdapterPortValidationOptions extends GatewayPortValidationOptions { + gatewayPort: number; } /** @@ -61,6 +66,11 @@ export const BEDROCK_RUNTIME_ADAPTER_PORT = parsePort( "NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT", 11436, ); +/** OpenRouter header-injection adapter port (default 11437, override via NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT). */ +export const OPENROUTER_RUNTIME_ADAPTER_PORT = parsePort( + "NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT", + 11437, +); export function validateGatewayPort( envVar: string, @@ -78,6 +88,7 @@ export function validateGatewayPort( { label: "Ollama inference", port: 11434 }, { label: "Ollama auth proxy", port: 11435 }, { label: "Bedrock Runtime adapter", port: 11436 }, + { label: "OpenRouter Runtime adapter", port: 11437 }, ]; const reservedDefault = reservedDefaults.find((entry) => entry.port === port); if (reservedDefault) { @@ -95,6 +106,10 @@ export function validateGatewayPort( envVar: "NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT", port: options.bedrockRuntimeAdapterPort, }, + { + envVar: "NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT", + port: options.openrouterRuntimeAdapterPort, + }, ]; const conflict = conflicts.find((entry) => entry.port === port); if (conflict) { @@ -114,6 +129,49 @@ export function parseGatewayPort( return port; } +export function validateOpenRouterRuntimeAdapterPort( + envVar: string, + port: number, + options: RuntimeAdapterPortValidationOptions, +): void { + if (port >= options.dashboardRangeStart && port <= options.dashboardRangeEnd) { + throw new Error( + `Invalid port: ${envVar}="${port}" — must not overlap the ${options.dashboardRangeStart}-${options.dashboardRangeEnd} dashboard port range`, + ); + } + + const reservedDefaults = [ + { label: "vLLM / NIM inference", port: 8000 }, + { label: "Ollama inference", port: 11434 }, + { label: "Ollama auth proxy", port: 11435 }, + { label: "Bedrock Runtime adapter", port: 11436 }, + ]; + const reservedDefault = reservedDefaults.find((entry) => entry.port === port); + if (reservedDefault) { + throw new Error( + `Invalid port: ${envVar}="${port}" — must not overlap the ${reservedDefault.label} default port (${reservedDefault.port})`, + ); + } + + const conflicts = [ + { envVar: "NEMOCLAW_GATEWAY_PORT", port: options.gatewayPort }, + { envVar: "NEMOCLAW_DASHBOARD_PORT", port: options.dashboardPort }, + { envVar: "NEMOCLAW_VLLM_PORT", port: options.vllmPort }, + { envVar: "NEMOCLAW_OLLAMA_PORT", port: options.ollamaPort }, + { envVar: "NEMOCLAW_OLLAMA_PROXY_PORT", port: options.ollamaProxyPort }, + { + envVar: "NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT", + port: options.bedrockRuntimeAdapterPort, + }, + ]; + const conflict = conflicts.find((entry) => entry.port === port); + if (conflict) { + throw new Error( + `Invalid port: ${envVar}="${port}" — conflicts with ${conflict.envVar} (${conflict.port})`, + ); + } +} + /** Default OpenShell gateway port when NEMOCLAW_GATEWAY_PORT is unset. */ export const DEFAULT_GATEWAY_PORT = 8080; /** OpenShell gateway port (default 8080, override via NEMOCLAW_GATEWAY_PORT). */ @@ -125,4 +183,5 @@ export const GATEWAY_PORT = parseGatewayPort("NEMOCLAW_GATEWAY_PORT", DEFAULT_GA ollamaPort: OLLAMA_PORT, ollamaProxyPort: OLLAMA_PROXY_PORT, bedrockRuntimeAdapterPort: BEDROCK_RUNTIME_ADAPTER_PORT, + openrouterRuntimeAdapterPort: OPENROUTER_RUNTIME_ADAPTER_PORT, }); diff --git a/src/lib/inference/nvidia-featured-models.test.ts b/src/lib/inference/nvidia-featured-models.test.ts index 9a828c910c..493fe0844d 100644 --- a/src/lib/inference/nvidia-featured-models.test.ts +++ b/src/lib/inference/nvidia-featured-models.test.ts @@ -11,7 +11,6 @@ import { NVIDIA_FEATURED_MODELS_URL, parseNvidiaFeaturedModels, } from "./nvidia-featured-models"; -import { OPENROUTER_FEATURED_MODELS_URL } from "./openrouter"; describe("NVIDIA featured model catalog", () => { it("prefixes bare Nemotron catalog IDs with the canonical endpoint namespace (#5827)", () => { @@ -110,14 +109,12 @@ describe("NVIDIA featured model catalog", () => { }); }); - it("can fetch OpenRouter's featured catalog from its own endpoint (#5826)", () => { + it("can fetch an alternate featured catalog from a configured endpoint", () => { const result = fetchNvidiaFeaturedModels({ - catalogUrl: OPENROUTER_FEATURED_MODELS_URL, + catalogUrl: "https://example.invalid/featured-models-partner.json", retiredModelIds: [], runCurlProbeImpl: (argv) => { - expect(argv.at(-1)).toBe( - "https://assets.ngc.nvidia.com/products/api-catalog/featured-models-openrouter.json", - ); + expect(argv.at(-1)).toBe("https://example.invalid/featured-models-partner.json"); return { ok: true, httpStatus: 200, @@ -197,10 +194,10 @@ describe("NVIDIA featured model catalog", () => { ]); }); - it("uses the configured catalog label in fallback warnings (#5826)", () => { + it("uses the configured catalog label in fallback warnings", () => { const warnings: string[] = []; getNvidiaFeaturedModelOptions({ - catalogLabel: "OpenRouter's featured model catalog", + catalogLabel: "Partner featured model catalog", runCurlProbeImpl: () => ({ ok: false, httpStatus: 503, @@ -213,7 +210,7 @@ describe("NVIDIA featured model catalog", () => { }); expect(warnings).toEqual([ - " Warning: failed to load OpenRouter's featured model catalog; falling back to the bundled list (service unavailable; HTTP 503).", + " Warning: failed to load Partner featured model catalog; falling back to the bundled list (service unavailable; HTTP 503).", ]); }); diff --git a/src/lib/inference/openrouter-runtime-adapter-common.ts b/src/lib/inference/openrouter-runtime-adapter-common.ts new file mode 100644 index 0000000000..6db8301013 --- /dev/null +++ b/src/lib/inference/openrouter-runtime-adapter-common.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import http from "node:http"; +import path from "node:path"; + +import { compactText } from "../core/url-utils"; +import { OPENROUTER_DEFAULT_HEADERS, OPENROUTER_ENDPOINT_URL } from "./openrouter"; +import { + DEFAULT_LOCAL_ADAPTER_STATE_DIR, + appendLocalAdapterJsonLine, + type JsonObject, +} from "./local-adapter-lifecycle"; + +export const STATE_DIR = DEFAULT_LOCAL_ADAPTER_STATE_DIR; +export const PID_PATH = path.join(STATE_DIR, "openrouter-runtime-adapter.pid"); +export const STATE_PATH = path.join(STATE_DIR, "openrouter-runtime-adapter.json"); +export const LOCK_PATH = path.join(STATE_DIR, "openrouter-runtime-adapter.lock"); +export const LOG_PATH = path.join(STATE_DIR, "openrouter-runtime-adapter.log"); +export const ADAPTER_NAME = "openrouter-runtime"; +export const OPENROUTER_RUNTIME_ADAPTER_AUTHORIZATION_HASH_ENV = + "NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_AUTHORIZATION_HASH"; + +export type AdapterLogFields = Record; +export type AdapterLogger = (event: string, fields?: AdapterLogFields) => void; + +function normalizeLogField( + value: string | number | boolean | null | undefined, +): string | number | boolean | null { + if (value === undefined) return null; + if (typeof value === "string") return compactText(value).slice(0, 180); + return value; +} + +export function defaultAdapterLogger(event: string, fields: AdapterLogFields = {}): void { + try { + const payload: Record = { + ts: new Date().toISOString(), + event: normalizeLogField(event) as string, + }; + for (const [key, value] of Object.entries(fields)) { + payload[key] = normalizeLogField(value); + } + appendLocalAdapterJsonLine(LOG_PATH, payload); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`OpenRouter Runtime adapter log write failed: ${compactText(message)}`); + } +} + +export function logAdapterEvent( + logger: AdapterLogger, + event: string, + fields: AdapterLogFields = {}, +): void { + try { + logger(event, fields); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`OpenRouter Runtime adapter logger failed: ${compactText(message)}`); + } +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value as JsonObject) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson((value as JsonObject)[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function adapterConfigHash(upstreamBaseUrl = OPENROUTER_ENDPOINT_URL): string { + return crypto + .createHash("sha256") + .update( + stableJson({ + adapter: ADAPTER_NAME, + upstreamBaseUrl, + defaultHeaders: OPENROUTER_DEFAULT_HEADERS, + }), + ) + .digest("hex"); +} + +export function adapterAuthorizationHash(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export function normalizeAuthorizationHash(value: unknown): string | null { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null; +} + +function normalizeHttpStatus(status: number): number { + return Number.isInteger(status) && status >= 100 && status <= 599 ? status : 500; +} + +export function sendJson(res: http.ServerResponse, status: number, body: JsonObject): void { + res.statusCode = normalizeHttpStatus(status); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} diff --git a/src/lib/inference/openrouter-runtime-adapter-entry.ts b/src/lib/inference/openrouter-runtime-adapter-entry.ts new file mode 100644 index 0000000000..8a8d837bec --- /dev/null +++ b/src/lib/inference/openrouter-runtime-adapter-entry.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { startOpenRouterRuntimeAdapterFromEnv } from "./openrouter-runtime-adapter-server"; + +try { + startOpenRouterRuntimeAdapterFromEnv(); +} catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +} diff --git a/src/lib/inference/openrouter-runtime-adapter-forward.ts b/src/lib/inference/openrouter-runtime-adapter-forward.ts new file mode 100644 index 0000000000..a3811a8cb2 --- /dev/null +++ b/src/lib/inference/openrouter-runtime-adapter-forward.ts @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import http from "node:http"; +import https from "node:https"; + +import { compactText } from "../core/url-utils"; +import { OPENROUTER_DEFAULT_HEADERS } from "./openrouter"; +import { sendJson } from "./openrouter-runtime-adapter-common"; + +export const OPENROUTER_RUNTIME_ADAPTER_MAX_BODY_BYTES = 2 * 1024 * 1024; +const OPENROUTER_RUNTIME_ADAPTER_BODY_TIMEOUT_MS = 30_000; +const OPENROUTER_RUNTIME_ADAPTER_UPSTREAM_TIMEOUT_MS = 30_000; + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +class ForwardHttpError extends Error { + constructor( + readonly status: number, + message: string, + readonly code: string, + ) { + super(message); + } +} + +export function getBearerAuthorizationToken(actual: string | string[] | undefined): string | null { + const header = Array.isArray(actual) ? actual[0] : actual; + const match = typeof header === "string" ? header.match(/^Bearer\s+(\S+)$/) : null; + return match?.[1] ?? null; +} + +export function buildUpstreamUrl(upstreamBaseUrl: string, reqUrl: string | undefined): URL { + const incoming = new URL(reqUrl || "/", "http://127.0.0.1"); + const upstream = new URL(upstreamBaseUrl); + const basePath = upstream.pathname.replace(/\/+$/, ""); + const suffix = incoming.pathname.startsWith("/v1") + ? incoming.pathname.slice("/v1".length) + : incoming.pathname; + upstream.pathname = `${basePath}${suffix.startsWith("/") ? suffix : `/${suffix}`}`; + upstream.search = incoming.search; + return upstream; +} + +export function buildForwardRequestHeaders(req: http.IncomingMessage): http.OutgoingHttpHeaders { + const headers: http.OutgoingHttpHeaders = {}; + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined || HOP_BY_HOP_HEADERS.has(name.toLowerCase())) continue; + headers[name] = value; + } + for (const [name, value] of OPENROUTER_DEFAULT_HEADERS) { + headers[name] = value; + } + return headers; +} + +function buildForwardResponseHeaders(source: http.IncomingHttpHeaders): http.OutgoingHttpHeaders { + const headers: http.OutgoingHttpHeaders = {}; + for (const [name, value] of Object.entries(source)) { + if (value === undefined || HOP_BY_HOP_HEADERS.has(name.toLowerCase())) continue; + headers[name] = value; + } + return headers; +} + +function readBoundedRequestBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const contentLength = Number(req.headers["content-length"] || 0); + if ( + Number.isFinite(contentLength) && + contentLength > OPENROUTER_RUNTIME_ADAPTER_MAX_BODY_BYTES + ) { + reject(new ForwardHttpError(413, "Request body is too large.", "request_too_large")); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new ForwardHttpError(408, "Request body timed out.", "request_timeout")); + req.destroy(); + }, OPENROUTER_RUNTIME_ADAPTER_BODY_TIMEOUT_MS); + + req.on("data", (chunk: Buffer) => { + if (settled) return; + size += chunk.length; + if (size > OPENROUTER_RUNTIME_ADAPTER_MAX_BODY_BYTES) { + settled = true; + clearTimeout(timer); + reject(new ForwardHttpError(413, "Request body is too large.", "request_too_large")); + return; + } + chunks.push(Buffer.from(chunk)); + }); + req.on("end", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(Buffer.concat(chunks)); + }); + req.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + }); +} + +function sendForwardError(res: http.ServerResponse, err: unknown): number { + const status = err instanceof ForwardHttpError ? err.status : 502; + const code = err instanceof ForwardHttpError ? err.code : "openrouter_runtime_error"; + const message = err instanceof ForwardHttpError ? err.message : "OpenRouter request failed."; + if (!res.headersSent) { + sendJson(res, status, { + error: { + message: compactText(message), + type: code, + code, + }, + }); + } else { + res.destroy(err instanceof Error ? err : undefined); + } + return status; +} + +export async function forwardOpenRouterRequest(options: { + req: http.IncomingMessage; + res: http.ServerResponse; + upstreamBaseUrl: string; + upstreamTimeoutMs?: number; +}): Promise { + const upstreamUrl = buildUpstreamUrl(options.upstreamBaseUrl, options.req.url); + const transport = upstreamUrl.protocol === "http:" ? http : https; + let body: Buffer; + try { + body = await readBoundedRequestBody(options.req); + } catch (err) { + return sendForwardError(options.res, err); + } + return new Promise((resolve) => { + let settled = false; + const resolveOnce = (status: number) => { + if (settled) return; + settled = true; + resolve(status); + }; + const failRequest = (err: unknown) => { + resolveOnce(sendForwardError(options.res, err)); + }; + const headers = buildForwardRequestHeaders(options.req); + headers["content-length"] = String(body.length); + const upstreamReq = transport.request( + upstreamUrl, + { + method: options.req.method, + headers, + }, + (upstreamRes) => { + const status = upstreamRes.statusCode || 502; + options.res.writeHead(status, buildForwardResponseHeaders(upstreamRes.headers)); + upstreamRes.once("aborted", () => { + failRequest( + new ForwardHttpError( + 502, + "OpenRouter upstream response aborted.", + "upstream_response_aborted", + ), + ); + }); + upstreamRes.once("error", failRequest); + upstreamRes.pipe(options.res); + upstreamRes.once("end", () => resolveOnce(status)); + }, + ); + upstreamReq.setTimeout( + options.upstreamTimeoutMs ?? OPENROUTER_RUNTIME_ADAPTER_UPSTREAM_TIMEOUT_MS, + () => { + upstreamReq.destroy( + new ForwardHttpError(504, "OpenRouter upstream request timed out.", "upstream_timeout"), + ); + }, + ); + upstreamReq.on("error", (err) => { + failRequest(err); + }); + upstreamReq.end(body); + }); +} diff --git a/src/lib/inference/openrouter-runtime-adapter-lifecycle.ts b/src/lib/inference/openrouter-runtime-adapter-lifecycle.ts new file mode 100644 index 0000000000..166df0fe10 --- /dev/null +++ b/src/lib/inference/openrouter-runtime-adapter-lifecycle.ts @@ -0,0 +1,311 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; + +import { + BEDROCK_RUNTIME_ADAPTER_PORT, + DASHBOARD_PORT, + DASHBOARD_PORT_RANGE_END, + DASHBOARD_PORT_RANGE_START, + GATEWAY_PORT, + OLLAMA_PORT, + OLLAMA_PROXY_PORT, + OPENROUTER_RUNTIME_ADAPTER_PORT, + VLLM_PORT, + validateOpenRouterRuntimeAdapterPort, +} from "../core/ports"; +import { run, runCapture } from "../runner"; +import { buildSubprocessEnv } from "../subprocess-env"; +import { + OPENROUTER_CREDENTIAL_ENV, + OPENROUTER_ENDPOINT_URL, + OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_HOST, + OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_OPENAI_BASE_URL, + OPENROUTER_RUNTIME_ADAPTER_OPENAI_BASE_URL, +} from "./openrouter"; +import { + ensureLocalAdapterStateDir, + isLocalAdapterProcess, + killLocalAdapterPid, + loadLocalAdapterPid, + persistLocalAdapterPid, + readLocalAdapterJsonFile, + removeLocalAdapterFile, + spawnDetachedNodeAdapter, + waitForLocalAdapterHealth, + writeLocalAdapterJsonFile, + type JsonObject, +} from "./local-adapter-lifecycle"; +import { + ADAPTER_NAME, + LOCK_PATH, + LOG_PATH, + OPENROUTER_RUNTIME_ADAPTER_AUTHORIZATION_HASH_ENV, + PID_PATH, + STATE_DIR, + STATE_PATH, + adapterAuthorizationHash, + adapterConfigHash, + normalizeAuthorizationHash, +} from "./openrouter-runtime-adapter-common"; + +const PROCESS_NEEDLE = "openrouter-runtime-adapter"; +const LOCK_RETRY_ATTEMPTS = 100; +const LOCK_RETRY_MS = 100; +const STALE_LOCK_MS = 30_000; + +type AdapterRoute = { + baseUrl: string; + localBaseUrl: string; + logPath: string; + credentialEnv: string; +}; + +type EnsureOpenRouterRuntimeAdapterOptions = { + authorizationToken?: string | null; +}; + +function sleepMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function removeStaleLock(): void { + try { + const ageMs = Date.now() - fs.statSync(LOCK_PATH).mtimeMs; + if (ageMs > STALE_LOCK_MS) fs.unlinkSync(LOCK_PATH); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } +} + +function tryAcquireAdapterLock(): (() => void) | null { + ensureLocalAdapterStateDir(STATE_DIR); + removeStaleLock(); + try { + const fd = fs.openSync(LOCK_PATH, "wx", 0o600); + fs.writeFileSync(fd, `${process.pid}\n${new Date().toISOString()}\n`); + fs.closeSync(fd); + return () => { + try { + fs.unlinkSync(LOCK_PATH); + } catch { + /* best-effort lock cleanup */ + } + }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") return null; + throw err; + } +} + +async function withAdapterLock(operation: () => Promise): Promise { + for (let attempt = 0; attempt < LOCK_RETRY_ATTEMPTS; attempt++) { + const release = tryAcquireAdapterLock(); + if (release) { + try { + return await operation(); + } finally { + release(); + } + } + await sleepMs(LOCK_RETRY_MS); + } + throw new Error("OpenRouter Runtime adapter startup is already in progress"); +} + +function loadPersistedPid(): number | null { + return loadLocalAdapterPid(PID_PATH); +} + +function isAdapterProcess(pid: number | null | undefined): boolean { + return isLocalAdapterProcess(pid, PROCESS_NEEDLE, runCapture); +} + +function killStaleAdapter(): void { + killLocalAdapterPid({ + pidPath: PID_PATH, + processNeedle: PROCESS_NEEDLE, + run, + runCapture, + }); +} + +function cleanupFailedAdapterStartup(): void { + killStaleAdapter(); + removeLocalAdapterFile(STATE_PATH); +} + +function getAdapterScriptPath(): string { + return path.join(__dirname, "openrouter-runtime-adapter-entry.js"); +} + +function probeAdapterHealth( + options: { port?: number; configHash?: string | null; authorizationHash?: string | null } = {}, +): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_HOST, + port: options.port || OPENROUTER_RUNTIME_ADAPTER_PORT, + path: "/health", + method: "GET", + timeout: 1000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + res.on("end", () => { + if (res.statusCode !== 200) { + resolve(false); + return; + } + try { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as JsonObject; + resolve( + body.adapter === ADAPTER_NAME && + (!options.configHash || body.configHash === options.configHash) && + (!options.authorizationHash || + body.authorizationHash === options.authorizationHash), + ); + } catch { + resolve(false); + } + }); + }, + ); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + req.on("error", () => resolve(false)); + req.end(); + }); +} + +async function waitForAdapterHealth( + configHash: string, + authorizationHash: string, + port = OPENROUTER_RUNTIME_ADAPTER_PORT, +): Promise { + return waitForLocalAdapterHealth( + () => probeAdapterHealth({ port, configHash, authorizationHash }), + { + attempts: 20, + intervalMs: 100, + }, + ); +} + +function adapterRoute(): AdapterRoute { + return { + baseUrl: OPENROUTER_RUNTIME_ADAPTER_OPENAI_BASE_URL, + localBaseUrl: OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_OPENAI_BASE_URL, + logPath: LOG_PATH, + credentialEnv: OPENROUTER_CREDENTIAL_ENV, + }; +} + +function validateAdapterPortConfiguration(): void { + validateOpenRouterRuntimeAdapterPort( + "NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT", + OPENROUTER_RUNTIME_ADAPTER_PORT, + { + dashboardPort: DASHBOARD_PORT, + dashboardRangeStart: DASHBOARD_PORT_RANGE_START, + dashboardRangeEnd: DASHBOARD_PORT_RANGE_END, + gatewayPort: GATEWAY_PORT, + vllmPort: VLLM_PORT, + ollamaPort: OLLAMA_PORT, + ollamaProxyPort: OLLAMA_PROXY_PORT, + bedrockRuntimeAdapterPort: BEDROCK_RUNTIME_ADAPTER_PORT, + openrouterRuntimeAdapterPort: OPENROUTER_RUNTIME_ADAPTER_PORT, + }, + ); +} + +function resolveAuthorizationHash( + authorizationToken: string | null | undefined, + priorState: JsonObject | null, +): string { + const seededHash = + authorizationToken && authorizationToken.trim() + ? adapterAuthorizationHash(authorizationToken.trim()) + : normalizeAuthorizationHash(priorState?.authorizationHash); + if (!seededHash) { + throw new Error( + "OpenRouter Runtime adapter requires OPENROUTER_API_KEY once to bind adapter authorization before reusing a gateway-held credential.", + ); + } + return seededHash; +} + +async function ensureOpenRouterRuntimeAdapterLocked( + options: EnsureOpenRouterRuntimeAdapterOptions = {}, +): Promise { + validateAdapterPortConfiguration(); + const upstreamBaseUrl = OPENROUTER_ENDPOINT_URL; + const configHash = adapterConfigHash(upstreamBaseUrl); + const priorState = readLocalAdapterJsonFile(STATE_PATH); + const authorizationHash = resolveAuthorizationHash(options.authorizationToken, priorState); + const priorPid = loadPersistedPid(); + if ( + isAdapterProcess(priorPid) && + priorState?.upstreamBaseUrl === upstreamBaseUrl && + priorState?.configHash === configHash && + normalizeAuthorizationHash(priorState?.authorizationHash) === authorizationHash && + (await probeAdapterHealth({ configHash, authorizationHash })) + ) { + return adapterRoute(); + } + + killStaleAdapter(); + const child = spawnDetachedNodeAdapter({ + scriptPath: getAdapterScriptPath(), + env: { + NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT: String(OPENROUTER_RUNTIME_ADAPTER_PORT), + [OPENROUTER_RUNTIME_ADAPTER_AUTHORIZATION_HASH_ENV]: authorizationHash, + }, + buildEnv: buildSubprocessEnv, + }); + try { + persistLocalAdapterPid(PID_PATH, child.pid); + + if (!(await waitForAdapterHealth(configHash, authorizationHash))) { + throw new Error( + `OpenRouter Runtime adapter did not become healthy on ${OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_OPENAI_BASE_URL}`, + ); + } + + writeLocalAdapterJsonFile(STATE_PATH, { + upstreamBaseUrl, + configHash, + authorizationHash, + pid: child.pid ?? null, + updatedAt: new Date().toISOString(), + }); + if (!(await probeAdapterHealth({ configHash, authorizationHash }))) { + throw new Error( + `OpenRouter Runtime adapter health changed before registration on ${OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_OPENAI_BASE_URL}`, + ); + } + } catch (err) { + cleanupFailedAdapterStartup(); + throw err; + } + + return adapterRoute(); +} + +export async function ensureOpenRouterRuntimeAdapter( + options: EnsureOpenRouterRuntimeAdapterOptions = {}, +): Promise { + return withAdapterLock(() => ensureOpenRouterRuntimeAdapterLocked(options)); +} + +export const __test = { + getAdapterScriptPath, + probeAdapterHealth, +}; diff --git a/src/lib/inference/openrouter-runtime-adapter-server.ts b/src/lib/inference/openrouter-runtime-adapter-server.ts new file mode 100644 index 0000000000..90854e5466 --- /dev/null +++ b/src/lib/inference/openrouter-runtime-adapter-server.ts @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import http from "node:http"; + +import { OPENROUTER_RUNTIME_ADAPTER_PORT } from "../core/ports"; +import { compactText } from "../core/url-utils"; +import { + OPENROUTER_DEFAULT_HEADERS, + OPENROUTER_ENDPOINT_URL, + OPENROUTER_RUNTIME_ADAPTER_BIND_HOST, + OPENROUTER_RUNTIME_ADAPTER_OPENAI_BASE_URL, +} from "./openrouter"; +import { + ADAPTER_NAME, + LOG_PATH, + OPENROUTER_RUNTIME_ADAPTER_AUTHORIZATION_HASH_ENV, + adapterAuthorizationHash, + adapterConfigHash, + defaultAdapterLogger, + logAdapterEvent, + normalizeAuthorizationHash, + sendJson, + type AdapterLogger, +} from "./openrouter-runtime-adapter-common"; +import { + forwardOpenRouterRequest, + getBearerAuthorizationToken, +} from "./openrouter-runtime-adapter-forward"; + +const ALLOWED_POST_PATHS = new Set(["/v1/chat/completions"]); + +function isAllowedRequest(method: string | undefined, pathname: string): boolean { + return method === "POST" && ALLOWED_POST_PATHS.has(pathname); +} + +function timingSafeHashEqual(actualHash: string, expectedHash: string): boolean { + const actual = Buffer.from(actualHash, "hex"); + const expected = Buffer.from(expectedHash, "hex"); + return crypto.timingSafeEqual(actual, expected); +} + +function requireAuthorizationHash(value: unknown): string { + const normalized = normalizeAuthorizationHash(value); + if (!normalized) { + throw new Error("OpenRouter Runtime adapter authorization hash is required"); + } + return normalized; +} + +function isExpectedBearerAuthorization( + actual: string | string[] | undefined, + expectedAuthorizationHash: string, +): boolean { + const token = getBearerAuthorizationToken(actual) ?? ""; + return timingSafeHashEqual(adapterAuthorizationHash(token), expectedAuthorizationHash); +} + +export function createOpenRouterRuntimeAdapterServer( + options: { + upstreamBaseUrl?: string; + logger?: AdapterLogger; + upstreamTimeoutMs?: number; + authorizationHash?: string | null; + } = {}, +): http.Server { + const upstreamBaseUrl = options.upstreamBaseUrl || OPENROUTER_ENDPOINT_URL; + const configHash = adapterConfigHash(upstreamBaseUrl); + const logger = options.logger || defaultAdapterLogger; + const authorizationHash = requireAuthorizationHash(options.authorizationHash); + return http.createServer(async (req, res) => { + const started = Date.now(); + const url = new URL(req.url || "/", "http://127.0.0.1"); + try { + if (req.method === "GET" && url.pathname === "/health") { + sendJson(res, 200, { + ok: true, + adapter: ADAPTER_NAME, + configHash, + authorizationHash, + headerNames: OPENROUTER_DEFAULT_HEADERS.map(([name]) => name), + }); + return; + } + if (!isExpectedBearerAuthorization(req.headers.authorization, authorizationHash)) { + sendJson(res, 401, { + error: { message: "Unauthorized", type: "unauthorized", code: "unauthorized" }, + }); + logAdapterEvent(logger, "request_rejected", { + method: req.method || "unknown", + path: url.pathname, + status: 401, + reason: "unauthorized", + durationMs: Date.now() - started, + }); + return; + } + if (!isAllowedRequest(req.method, url.pathname)) { + sendJson(res, 404, { + error: { message: "Not found", type: "not_found", code: "not_found" }, + }); + logAdapterEvent(logger, "request_rejected", { + method: req.method || "unknown", + path: url.pathname, + status: 404, + reason: "not_found", + durationMs: Date.now() - started, + }); + return; + } + + const status = await forwardOpenRouterRequest({ + req, + res, + upstreamBaseUrl, + upstreamTimeoutMs: options.upstreamTimeoutMs, + }); + logAdapterEvent(logger, "request_completed", { + method: req.method || "unknown", + path: url.pathname, + status, + durationMs: Date.now() - started, + }); + } catch (err) { + logAdapterEvent(logger, "request_failed", { + method: req.method || "unknown", + path: url.pathname, + status: 502, + durationMs: Date.now() - started, + }); + if (!res.headersSent) { + sendJson(res, 502, { + error: { + message: compactText("OpenRouter request failed."), + type: "openrouter_runtime_error", + code: "openrouter_runtime_error", + }, + }); + } else { + res.end(); + } + } + }); +} + +export function startOpenRouterRuntimeAdapterFromEnv(): http.Server { + const port = Number( + process.env.NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT || OPENROUTER_RUNTIME_ADAPTER_PORT, + ); + const authorizationHash = normalizeAuthorizationHash( + process.env[OPENROUTER_RUNTIME_ADAPTER_AUTHORIZATION_HASH_ENV], + ); + if (!Number.isInteger(port) || port <= 0) { + throw new Error("NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT must be a valid port"); + } + if (!authorizationHash) { + throw new Error(`${OPENROUTER_RUNTIME_ADAPTER_AUTHORIZATION_HASH_ENV} is required`); + } + + const server = createOpenRouterRuntimeAdapterServer({ authorizationHash }); + server.listen(port, OPENROUTER_RUNTIME_ADAPTER_BIND_HOST, () => { + defaultAdapterLogger("adapter_ready", { + bindHost: OPENROUTER_RUNTIME_ADAPTER_BIND_HOST, + port, + sandboxRoute: OPENROUTER_RUNTIME_ADAPTER_OPENAI_BASE_URL, + logPath: LOG_PATH, + }); + console.log( + `OpenRouter Runtime adapter listening on ${OPENROUTER_RUNTIME_ADAPTER_BIND_HOST}:${port}; sandbox route ${OPENROUTER_RUNTIME_ADAPTER_OPENAI_BASE_URL}; log ${LOG_PATH}`, + ); + }); + return server; +} diff --git a/src/lib/inference/openrouter-runtime-adapter.test.ts b/src/lib/inference/openrouter-runtime-adapter.test.ts new file mode 100644 index 0000000000..dd2ee2440d --- /dev/null +++ b/src/lib/inference/openrouter-runtime-adapter.test.ts @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { OPENROUTER_DEFAULT_HEADERS } from "./openrouter"; +import { + adapterAuthorizationHash, + createOpenRouterRuntimeAdapterServer, +} from "./openrouter-runtime-adapter"; +import { OPENROUTER_RUNTIME_ADAPTER_MAX_BODY_BYTES } from "./openrouter-runtime-adapter-forward"; + +const servers: http.Server[] = []; +const OPENROUTER_TEST_TOKEN = "sk-or-test"; +const OPENROUTER_TEST_AUTHORIZATION_HASH = adapterAuthorizationHash(OPENROUTER_TEST_TOKEN); + +afterEach(async () => { + await Promise.all( + servers.map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + servers.length = 0; +}); + +function listen(server: http.Server): Promise { + servers.push(server); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + expect(address).toEqual(expect.objectContaining({ address: "127.0.0.1" })); + resolve(`http://127.0.0.1:${(address as AddressInfo).port}`); + }); + }); +} + +function createTestAdapter( + options: Parameters[0] = {}, +): http.Server { + return createOpenRouterRuntimeAdapterServer({ + authorizationHash: OPENROUTER_TEST_AUTHORIZATION_HASH, + ...options, + }); +} + +function readRequestBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +describe("OpenRouter Runtime adapter", () => { + it("forwards chat completions with OpenRouter attribution headers (#5826)", async () => { + const upstreamRequests: Array<{ + method: string | undefined; + url: string | undefined; + headers: http.IncomingHttpHeaders; + body: string; + }> = []; + const upstream = http.createServer(async (req, res) => { + upstreamRequests.push({ + method: req.method, + url: req.url, + headers: req.headers, + body: await readRequestBody(req), + }); + res.writeHead(200, { + "Content-Type": "application/json", + "X-Upstream-Trace": "ok", + }); + res.end(JSON.stringify({ id: "chatcmpl-test", choices: [] })); + }); + const upstreamBaseUrl = await listen(upstream); + const adapter = createTestAdapter({ + upstreamBaseUrl: `${upstreamBaseUrl}/api/v1`, + }); + const adapterBaseUrl = await listen(adapter); + const payload = JSON.stringify({ + model: "moonshotai/kimi-k2.6", + messages: [{ role: "user", content: "hello" }], + }); + + const response = await fetch(`${adapterBaseUrl}/v1/chat/completions?trace=1`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_TEST_TOKEN}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://example.invalid/", + "X-OpenRouter-Title": "Wrong Title", + }, + body: payload, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-upstream-trace")).toBe("ok"); + await expect(response.json()).resolves.toMatchObject({ id: "chatcmpl-test" }); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]).toMatchObject({ + method: "POST", + url: "/api/v1/chat/completions?trace=1", + body: payload, + }); + expect(upstreamRequests[0].headers.authorization).toBe(`Bearer ${OPENROUTER_TEST_TOKEN}`); + expect(upstreamRequests[0].headers["http-referer"]).toBe(OPENROUTER_DEFAULT_HEADERS[0][1]); + expect(upstreamRequests[0].headers["x-openrouter-title"]).toBe( + OPENROUTER_DEFAULT_HEADERS[1][1], + ); + }); + + it("requires bearer auth for runtime paths and exposes safe health (#5826)", async () => { + const upstreamHandler = vi.fn((_req: http.IncomingMessage, res: http.ServerResponse) => + res.end("unexpected"), + ); + const upstream = http.createServer(upstreamHandler); + const upstreamBaseUrl = await listen(upstream); + const adapter = createTestAdapter({ + upstreamBaseUrl: `${upstreamBaseUrl}/api/v1`, + }); + const adapterBaseUrl = await listen(adapter); + + const health = await fetch(`${adapterBaseUrl}/health`); + expect(health.status).toBe(200); + const healthBody = (await health.json()) as Record; + expect(healthBody).toMatchObject({ + ok: true, + adapter: "openrouter-runtime", + authorizationHash: OPENROUTER_TEST_AUTHORIZATION_HASH, + headerNames: ["HTTP-Referer", "X-OpenRouter-Title"], + }); + expect(JSON.stringify(healthBody)).not.toContain(OPENROUTER_TEST_TOKEN); + + const missingAuth = await fetch(`${adapterBaseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(missingAuth.status).toBe(401); + + const wrongAuth = await fetch(`${adapterBaseUrl}/v1/chat/completions`, { + method: "POST", + headers: { Authorization: "Bearer sk-or-other", "Content-Type": "application/json" }, + body: "{}", + }); + expect(wrongAuth.status).toBe(401); + + const unsupported = await fetch(`${adapterBaseUrl}/v1/models`, { + headers: { Authorization: `Bearer ${OPENROUTER_TEST_TOKEN}` }, + }); + expect(unsupported.status).toBe(404); + expect(upstreamHandler).not.toHaveBeenCalled(); + }); + + it("rejects oversized request bodies before reaching upstream (#5826)", async () => { + const upstreamHandler = vi.fn((_req: http.IncomingMessage, res: http.ServerResponse) => + res.end("unexpected"), + ); + const upstream = http.createServer(upstreamHandler); + const upstreamBaseUrl = await listen(upstream); + const adapter = createTestAdapter({ + upstreamBaseUrl: `${upstreamBaseUrl}/api/v1`, + }); + const adapterBaseUrl = await listen(adapter); + + const response = await fetch(`${adapterBaseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_TEST_TOKEN}`, + "Content-Type": "application/json", + }, + body: "x".repeat(OPENROUTER_RUNTIME_ADAPTER_MAX_BODY_BYTES + 1), + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "request_too_large" }, + }); + expect(upstreamHandler).not.toHaveBeenCalled(); + }); + + it("returns a generic error when upstream connection details fail (#5826)", async () => { + const adapter = createTestAdapter({ + upstreamBaseUrl: "http://127.0.0.1:1/api/v1", + upstreamTimeoutMs: 100, + }); + const adapterBaseUrl = await listen(adapter); + + const response = await fetch(`${adapterBaseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_TEST_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ model: "moonshotai/kimi-k2.6", messages: [] }), + }); + + expect(response.status).toBe(502); + const body = (await response.json()) as { error: { message: string; code: string } }; + expect(body.error).toMatchObject({ + message: "OpenRouter request failed.", + code: "openrouter_runtime_error", + }); + expect(JSON.stringify(body)).not.toContain("127.0.0.1"); + expect(JSON.stringify(body)).not.toContain("ECONNREFUSED"); + }); + + it("times out stalled upstream requests without hanging (#5826)", async () => { + const upstream = http.createServer(async (req, res) => { + await readRequestBody(req); + req.on("close", () => res.destroy()); + }); + const upstreamBaseUrl = await listen(upstream); + const adapter = createTestAdapter({ + upstreamBaseUrl: `${upstreamBaseUrl}/api/v1`, + upstreamTimeoutMs: 25, + }); + const adapterBaseUrl = await listen(adapter); + + const response = await fetch(`${adapterBaseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_TEST_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ model: "moonshotai/kimi-k2.6", messages: [] }), + }); + + expect(response.status).toBe(504); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "upstream_timeout" }, + }); + }); + + it("handles upstream mid-response aborts without crashing (#5826)", async () => { + const upstream = http.createServer(async (req, res) => { + await readRequestBody(req); + res.writeHead(200, { "Content-Type": "application/json", "Content-Length": "1024" }); + res.write('{"partial":'); + setImmediate(() => res.socket?.destroy()); + }); + const upstreamBaseUrl = await listen(upstream); + const adapter = createTestAdapter({ + upstreamBaseUrl: `${upstreamBaseUrl}/api/v1`, + upstreamTimeoutMs: 100, + }); + const adapterBaseUrl = await listen(adapter); + + await expect( + fetch(`${adapterBaseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_TEST_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ model: "moonshotai/kimi-k2.6", messages: [] }), + }).then((response) => response.text()), + ).rejects.toThrow(); + + await expect(fetch(`${adapterBaseUrl}/health`)).resolves.toMatchObject({ status: 200 }); + }); +}); diff --git a/src/lib/inference/openrouter-runtime-adapter.ts b/src/lib/inference/openrouter-runtime-adapter.ts new file mode 100644 index 0000000000..d939055aaf --- /dev/null +++ b/src/lib/inference/openrouter-runtime-adapter.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { adapterAuthorizationHash, adapterConfigHash } from "./openrouter-runtime-adapter-common"; + +export { + LOG_PATH, + adapterAuthorizationHash, + adapterConfigHash, +} from "./openrouter-runtime-adapter-common"; +export { ensureOpenRouterRuntimeAdapter } from "./openrouter-runtime-adapter-lifecycle"; +export { + createOpenRouterRuntimeAdapterServer, + startOpenRouterRuntimeAdapterFromEnv, +} from "./openrouter-runtime-adapter-server"; + +export const __test = { + adapterAuthorizationHash, + adapterConfigHash, +}; diff --git a/src/lib/inference/openrouter.ts b/src/lib/inference/openrouter.ts index 97b2b8f822..29a451d11d 100644 --- a/src/lib/inference/openrouter.ts +++ b/src/lib/inference/openrouter.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { OPENROUTER_RUNTIME_ADAPTER_PORT } from "../core/ports"; + export const OPENROUTER_ENDPOINT_URL = "https://openrouter.ai/api/v1"; -export const OPENROUTER_FEATURED_MODELS_URL = - "https://assets.ngc.nvidia.com/products/api-catalog/featured-models-openrouter.json"; export const OPENROUTER_HELP_URL = "https://openrouter.ai/workspaces/default/keys"; export const OPENROUTER_CREDENTIAL_ENV = "OPENROUTER_API_KEY"; export const OPENROUTER_PROVIDER_NAME = "openrouter-api"; @@ -15,6 +15,11 @@ export const OPENROUTER_DEFAULT_HEADERS = [ ["HTTP-Referer", "https://www.nvidia.com/nemoclaw/"], ["X-OpenRouter-Title", "NVIDIA NemoClaw"], ] as const; +export const OPENROUTER_RUNTIME_ADAPTER_BIND_HOST = "0.0.0.0"; +export const OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_HOST = "127.0.0.1"; +export const OPENROUTER_RUNTIME_ADAPTER_SANDBOX_HOST = "host.openshell.internal"; +export const OPENROUTER_RUNTIME_ADAPTER_OPENAI_BASE_URL = `http://${OPENROUTER_RUNTIME_ADAPTER_SANDBOX_HOST}:${OPENROUTER_RUNTIME_ADAPTER_PORT}/v1`; +export const OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_OPENAI_BASE_URL = `http://${OPENROUTER_RUNTIME_ADAPTER_LOOPBACK_HOST}:${OPENROUTER_RUNTIME_ADAPTER_PORT}/v1`; export function getOpenRouterCurlHeaders(): string[] { return OPENROUTER_DEFAULT_HEADERS.map(([name, value]) => `${name}: ${value}`); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 68403a1dc2..25909ff396 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -148,11 +148,10 @@ const { getRegistrySandboxMessagingPlan, MessagingHostStateApplier, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); -const { - applySessionRecovery, -}: typeof import("./onboard/session-recovery") = require("./onboard/session-recovery"); -const bedrockRuntimeOnboard: typeof import("./onboard/bedrock-runtime") = - require("./onboard/bedrock-runtime"); +const { applySessionRecovery } = + require("./onboard/session-recovery") as typeof import("./onboard/session-recovery"); +const bedrockRuntimeOnboard: typeof import("./onboard/bedrock-runtime") = require("./onboard/bedrock-runtime"); +const openrouterRuntimeOnboard: typeof import("./onboard/openrouter-runtime") = require("./onboard/openrouter-runtime"); const { installOllamaOnLinux, }: typeof import("./onboard/install-ollama-linux") = require("./onboard/install-ollama-linux"); @@ -3784,6 +3783,7 @@ function getSetupInferenceDeps(): SetupInferenceDeps { classifyApplyFailure, localInferenceTimeoutSecs: LOCAL_INFERENCE_TIMEOUT_SECS, bedrockRuntimeOnboard, + openrouterRuntimeOnboard, validateLocalProvider, getLocalProviderHealthCheck, getLocalProviderBaseUrl, diff --git a/src/lib/onboard/gateway-recovery.test.ts b/src/lib/onboard/gateway-recovery.test.ts index 64ae881aca..25b3cd734e 100644 --- a/src/lib/onboard/gateway-recovery.test.ts +++ b/src/lib/onboard/gateway-recovery.test.ts @@ -255,6 +255,16 @@ describe("gateway recovery", () => { expect(deps.runOpenshell).not.toHaveBeenCalled(); }); + it("rejects the OpenRouter Runtime adapter port before invoking OpenShell (#5826)", async () => { + const deps = createDeps(); + + await expect(startGatewayForRecovery({ gatewayPort: 11437 }, deps)).rejects.toThrow( + "OpenRouter Runtime adapter", + ); + + expect(deps.runOpenshell).not.toHaveBeenCalled(); + }); + it("fails closed on cross-port recovery when the Linux Docker-driver gateway is enabled", async () => { const deps = createDeps({ isLinuxDockerDriverGatewayEnabled: () => true }); diff --git a/src/lib/onboard/gateway-recovery.ts b/src/lib/onboard/gateway-recovery.ts index c9ed8527b1..c51442bf15 100644 --- a/src/lib/onboard/gateway-recovery.ts +++ b/src/lib/onboard/gateway-recovery.ts @@ -14,6 +14,7 @@ import { GATEWAY_PORT, OLLAMA_PORT, OLLAMA_PROXY_PORT, + OPENROUTER_RUNTIME_ADAPTER_PORT, VLLM_PORT, validateGatewayPort, } from "../core/ports"; @@ -99,6 +100,7 @@ function resolveGatewayRecoveryTarget(options: StartGatewayForRecoveryOptions = ollamaPort: OLLAMA_PORT, ollamaProxyPort: OLLAMA_PROXY_PORT, bedrockRuntimeAdapterPort: BEDROCK_RUNTIME_ADAPTER_PORT, + openrouterRuntimeAdapterPort: OPENROUTER_RUNTIME_ADAPTER_PORT, }); return { gatewayName, gatewayPort }; } diff --git a/src/lib/onboard/inference-providers/remote-openai-surface.test.ts b/src/lib/onboard/inference-providers/remote-openai-surface.test.ts index 47e12e4248..905b25e388 100644 --- a/src/lib/onboard/inference-providers/remote-openai-surface.test.ts +++ b/src/lib/onboard/inference-providers/remote-openai-surface.test.ts @@ -70,6 +70,9 @@ function createHarness() { bedrockRuntimeOnboard: { setupBedrockRuntimeInference: vi.fn(async () => ({ handled: false as const })), }, + openrouterRuntimeOnboard: { + setupOpenRouterRuntimeInference: vi.fn(async () => ({ handled: false as const })), + }, redact: vi.fn((value: string) => value), compactText: vi.fn((value: string) => value.trim()), probeOpenAiLikeEndpoint, diff --git a/src/lib/onboard/inference-providers/remote.ts b/src/lib/onboard/inference-providers/remote.ts index 0ff06a5ecc..0fd7b946eb 100644 --- a/src/lib/onboard/inference-providers/remote.ts +++ b/src/lib/onboard/inference-providers/remote.ts @@ -7,6 +7,7 @@ // `onboard/bedrock-runtime.ts` exactly as the inline branch did. import { getCompatibleAnthropicOpenAiSurfaceBaseUrl } from "../../inference/config"; +import { OPENROUTER_PROVIDER_NAME } from "../../inference/openrouter"; import { readGatewayProviderMetadata } from "../gateway-provider-metadata"; import { deleteProviderWithRecovery, parseAttachedSandboxes } from "../sandbox-provider-cleanup"; import type { RemoteProviderDeps, SetupInferenceResult } from "./types"; @@ -121,6 +122,7 @@ export async function setupRemoteProviderInference( endpointUrl: string | null; credentialEnv: string | null; reuseGatewayCredentialWithoutLocalKey?: boolean; + skipHostInferenceSmoke?: boolean; preferredInferenceApi?: string | null; pinnedAddresses?: readonly string[]; }, @@ -133,6 +135,7 @@ export async function setupRemoteProviderInference( endpointUrl, credentialEnv, reuseGatewayCredentialWithoutLocalKey, + skipHostInferenceSmoke, preferredInferenceApi, pinnedAddresses, } = args; @@ -152,6 +155,7 @@ export async function setupRemoteProviderInference( classifyApplyFailure, LOCAL_INFERENCE_TIMEOUT_SECS, bedrockRuntimeOnboard, + openrouterRuntimeOnboard, redact, compactText, } = deps; @@ -181,6 +185,28 @@ export async function setupRemoteProviderInference( log, }); if (bedrockSetup.handled) return { done: true, result: bedrockSetup.result }; + const openrouterCredentialEnv = credentialEnv || config.credentialEnv; + const openrouterCredentialValue = + provider === OPENROUTER_PROVIDER_NAME ? hydrateCredentialEnv(openrouterCredentialEnv) : null; + const openrouterSetup = await openrouterRuntimeOnboard.setupOpenRouterRuntimeInference({ + sandboxName, + provider, + model, + credentialEnv: openrouterCredentialEnv, + credentialValue: openrouterCredentialValue, + reuseGatewayCredentialWithoutLocalKey, + skipHostInferenceSmoke, + isNonInteractive, + runOpenshell, + upsertProvider, + verifyInferenceRoute, + verifyOnboardInferenceSmoke, + updateSandbox: registry.updateSandbox, + exitProcess, + error, + log, + }); + if (openrouterSetup.handled) return { done: true, result: openrouterSetup.result }; // #6294: an OpenAI-/chat/completions-only agent (dcode) coerced off Anthropic // Messages must talk to the gateway route over the openai_chat_completions // protocol, and OpenShell routes that protocol only for providers registered diff --git a/src/lib/onboard/inference-providers/types.ts b/src/lib/onboard/inference-providers/types.ts index 4e3423d209..7701612ba7 100644 --- a/src/lib/onboard/inference-providers/types.ts +++ b/src/lib/onboard/inference-providers/types.ts @@ -136,6 +136,26 @@ export type RemoteProviderDeps = CommonDeps & { log: (message: string) => void; }): Promise<{ handled: true; result: SetupInferenceResult } | { handled: false }>; }; + openrouterRuntimeOnboard: { + setupOpenRouterRuntimeInference(input: { + sandboxName: string | null; + provider: string; + model: string; + credentialEnv: string | null; + credentialValue: string | null; + reuseGatewayCredentialWithoutLocalKey?: boolean; + skipHostInferenceSmoke?: boolean; + isNonInteractive: () => boolean; + runOpenshell: RunOpenshell; + upsertProvider: UpsertProvider; + verifyInferenceRoute: VerifyInferenceRoute; + verifyOnboardInferenceSmoke: any; + updateSandbox: Registry["updateSandbox"]; + exitProcess: CommonDeps["exitProcess"]; + error: (message: string) => void; + log: (message: string) => void; + }): Promise<{ handled: true; result: SetupInferenceResult } | { handled: false }>; + }; }; // DNS lookup signature compatible with `dnsPromises.lookup(host, { all: true })` diff --git a/src/lib/onboard/nvidia-featured-model-selection.test.ts b/src/lib/onboard/nvidia-featured-model-selection.test.ts index 08e4b27a8c..7938ed0e23 100644 --- a/src/lib/onboard/nvidia-featured-model-selection.test.ts +++ b/src/lib/onboard/nvidia-featured-model-selection.test.ts @@ -73,17 +73,17 @@ describe("NVIDIA featured model selection", () => { }); }); - it("uses a provider-specific loading message when configured (#5826)", async () => { + it("uses a provider-specific loading message when configured", async () => { const writeLine = vi.fn(); vi.mocked(promptCloudModel).mockResolvedValueOnce("moonshotai/kimi-k2.6"); const session = createNvidiaFeaturedModelSession({ - loadingMessage: " Loading OpenRouter's featured model catalog...", + loadingMessage: " Loading partner featured model catalog...", writeLine, }); await expect(session.select(null, null, false)).resolves.toBe("moonshotai/kimi-k2.6"); - expect(writeLine).toHaveBeenCalledWith(" Loading OpenRouter's featured model catalog..."); + expect(writeLine).toHaveBeenCalledWith(" Loading partner featured model catalog..."); }); it("keeps requested, recovered, and environment models ahead of the agent default", async () => { diff --git a/src/lib/onboard/openrouter-runtime.ts b/src/lib/onboard/openrouter-runtime.ts new file mode 100644 index 0000000000..2a30fb4b7d --- /dev/null +++ b/src/lib/onboard/openrouter-runtime.ts @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { compactText } from "../core/url-utils"; +import { OPENROUTER_CREDENTIAL_ENV, OPENROUTER_PROVIDER_NAME } from "../inference/openrouter"; +import { ensureOpenRouterRuntimeAdapter } from "../inference/openrouter-runtime-adapter"; +import { redact } from "../runner"; +import * as registry from "../state/registry"; +import { LOCAL_INFERENCE_TIMEOUT_SECS } from "./env"; + +type RunOpenshell = ( + args: string[], + options?: { ignoreError?: boolean; suppressOutput?: boolean; timeout?: number }, +) => { status: number | null; stdout?: unknown; stderr?: unknown }; + +type UpsertProvider = ( + name: string, + type: string, + credentialEnv: string, + baseUrl: string | null, + env?: NodeJS.ProcessEnv, +) => { ok: boolean; message?: string; status?: number }; + +type SetupInferenceResult = { ok: true; retry?: undefined } | { retry: "selection" }; + +type OpenRouterRuntimeDependencies = { + exitProcess: (code: number) => never; + error: (message: string) => void; + log: (message: string) => void; +}; + +// TODO: Replace this NemoClaw runtime-adapter workaround when OpenShell exposes +// L7 middleware/default-header injection for provider routes. OpenRouter needs +// static HTTP-Referer and X-OpenRouter-Title headers at runtime, and today's +// OpenShell OpenAI-compatible profile cannot configure those per provider. +export async function setupOpenRouterRuntimeInference( + options: { + sandboxName: string | null; + provider: string; + model: string; + credentialEnv: string | null; + credentialValue: string | null; + reuseGatewayCredentialWithoutLocalKey?: boolean; + skipHostInferenceSmoke?: boolean; + isNonInteractive: () => boolean; + runOpenshell: RunOpenshell; + upsertProvider: UpsertProvider; + verifyInferenceRoute: (provider: string, model: string) => void; + verifyOnboardInferenceSmoke: (options: { + provider: string; + model: string; + endpointUrl?: string | null; + credentialEnv?: string | null; + forceOpenAiLike?: boolean; + }) => void; + ensureAdapter?: typeof ensureOpenRouterRuntimeAdapter; + updateSandbox?: typeof registry.updateSandbox; + } & OpenRouterRuntimeDependencies, +): Promise<{ handled: false } | { handled: true; result: SetupInferenceResult }> { + const { error, exitProcess, log } = options; + if (options.provider !== OPENROUTER_PROVIDER_NAME) return { handled: false }; + + const credentialEnv = options.credentialEnv || OPENROUTER_CREDENTIAL_ENV; + if (!options.credentialValue && !options.reuseGatewayCredentialWithoutLocalKey) { + error(` ${credentialEnv} is required to configure OpenRouter.`); + if (options.isNonInteractive()) return exitProcess(1); + return { handled: true, result: { retry: "selection" } }; + } + + let adapter: Awaited>; + try { + adapter = await (options.ensureAdapter ?? ensureOpenRouterRuntimeAdapter)({ + authorizationToken: options.credentialValue, + }); + } catch (err) { + error( + ` Failed to start OpenRouter Runtime adapter: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + if (options.isNonInteractive()) return exitProcess(1); + return { handled: true, result: { retry: "selection" } }; + } + + const env = options.credentialValue ? { [credentialEnv]: options.credentialValue } : {}; + const providerResult = options.upsertProvider( + options.provider, + "openai", + credentialEnv, + adapter.baseUrl, + env, + ); + if (!providerResult.ok) { + error(` ${providerResult.message}`); + if (options.isNonInteractive()) return exitProcess(providerResult.status || 1); + return { handled: true, result: { retry: "selection" } }; + } + log( + ` OpenRouter Runtime adapter ready: sandbox route ${adapter.baseUrl}, host log ${adapter.logPath}`, + ); + + const applyResult = options.runOpenshell( + [ + "inference", + "set", + "--no-verify", + "--provider", + options.provider, + "--model", + options.model, + "--timeout", + String(LOCAL_INFERENCE_TIMEOUT_SECS), + ], + { ignoreError: true }, + ); + if (applyResult.status !== 0) { + const message = + compactText(redact(`${applyResult.stderr || ""} ${applyResult.stdout || ""}`)) || + `Failed to configure inference provider '${options.provider}'.`; + error(` ${message}`); + if (options.isNonInteractive()) return exitProcess(applyResult.status || 1); + return { handled: true, result: { retry: "selection" } }; + } + + options.verifyInferenceRoute(options.provider, options.model); + if (options.skipHostInferenceSmoke === true || !options.credentialValue) { + log(" Reusing existing gateway credential; skipping host inference smoke."); + } else { + options.verifyOnboardInferenceSmoke({ + provider: options.provider, + model: options.model, + endpointUrl: adapter.localBaseUrl, + credentialEnv, + forceOpenAiLike: true, + }); + } + if (options.sandboxName) { + (options.updateSandbox ?? registry.updateSandbox)(options.sandboxName, { + model: options.model, + provider: options.provider, + }); + } + log(` ✓ Inference route set: ${options.provider} / ${options.model}`); + return { handled: true, result: { ok: true } }; +} diff --git a/src/lib/onboard/setup-inference-route-containment.test.ts b/src/lib/onboard/setup-inference-route-containment.test.ts index 07b9c959a5..bae6c9804b 100644 --- a/src/lib/onboard/setup-inference-route-containment.test.ts +++ b/src/lib/onboard/setup-inference-route-containment.test.ts @@ -51,6 +51,9 @@ describe("onboard shared gateway route containment", () => { bedrockRuntimeOnboard: { setupBedrockRuntimeInference: vi.fn(async () => ({ handled: false as const })), }, + openrouterRuntimeOnboard: { + setupOpenRouterRuntimeInference: vi.fn(async () => ({ handled: false as const })), + }, redact: (value: string) => value, compactText: (value: string) => value, log: vi.fn(), @@ -206,6 +209,12 @@ describe("onboard shared gateway route containment", () => { })), }, hydrateCredentialEnv: vi.fn(() => "secret"), + bedrockRuntimeOnboard: { + setupBedrockRuntimeInference: vi.fn(async () => ({ handled: false as const })), + }, + openrouterRuntimeOnboard: { + setupOpenRouterRuntimeInference: vi.fn(async () => ({ handled: false as const })), + }, redact: (value: string) => value, compactText: (value: string) => value, log: vi.fn(), diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index cd0c3cb2e1..03ed3fc3e5 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -59,6 +59,7 @@ type ProviderBranchDeps = Pick< | "promptValidationRecovery" | "classifyApplyFailure" | "bedrockRuntimeOnboard" + | "openrouterRuntimeOnboard" > & Pick< VllmDeps, @@ -350,6 +351,7 @@ export function createSetupInference( credentialEnv, reuseGatewayCredentialWithoutLocalKey: options.reuseGatewayCredentialWithoutLocalKey === true, + skipHostInferenceSmoke: options.skipHostInferenceSmoke === true, preferredInferenceApi: options.preferredInferenceApi ?? null, pinnedAddresses: endpointPinnedAddresses, }, @@ -361,6 +363,7 @@ export function createSetupInference( classifyApplyFailure: deps.classifyApplyFailure, LOCAL_INFERENCE_TIMEOUT_SECS: deps.localInferenceTimeoutSecs, bedrockRuntimeOnboard: deps.bedrockRuntimeOnboard, + openrouterRuntimeOnboard: deps.openrouterRuntimeOnboard, redact: deps.redact, compactText: deps.compactText, probeOpenAiLikeEndpoint: deps.probeOpenAiLikeEndpoint, diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts index ba4f69b69d..0d5926e4f0 100644 --- a/src/lib/onboard/setup-nim-flow.test.ts +++ b/src/lib/onboard/setup-nim-flow.test.ts @@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentDefinition } from "../agent/defs"; -import { OPENROUTER_FEATURED_MODELS_URL } from "../inference/openrouter"; import type { VllmProfile } from "../inference/vllm"; import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology"; import type { InferenceProviderHostState } from "./provider-host-state"; @@ -143,14 +142,16 @@ afterEach(() => { }); describe("createSetupNim", () => { - it("passes the Deep Agents manifest default to NVIDIA model selection", async () => { + it("passes the Deep Agents manifest default to shared NVIDIA/OpenRouter model selection", async () => { const ultra = "nvidia/nemotron-3-ultra-550b-a55b"; const log = vi.fn(); + const sharedSession = { select: async () => unexpected("featured model selection") }; const createNvidiaFeaturedModelSession = vi.fn< SetupNimFlowDeps["createNvidiaFeaturedModelSession"] - >(() => ({ select: async () => unexpected("featured model selection") })); + >(() => sharedSession); const handleRemoteProviderSelection = vi.fn( async (_args, state) => { + expect(state.openRouterFeaturedModels).toBe(state.nvidiaFeaturedModels); state.model = ultra; state.provider = "nvidia-prod"; state.endpointUrl = "https://integrate.api.nvidia.com/v1"; @@ -168,18 +169,11 @@ describe("createSetupNim", () => { await setupNim(null, null, dcodeAgent); + expect(createNvidiaFeaturedModelSession).toHaveBeenCalledTimes(1); expect(createNvidiaFeaturedModelSession).toHaveBeenCalledWith({ defaultModel: ultra, writeLine: log, }); - expect(createNvidiaFeaturedModelSession).toHaveBeenCalledWith({ - catalogLabel: "OpenRouter's featured model catalog", - catalogUrl: OPENROUTER_FEATURED_MODELS_URL, - defaultModel: ultra, - loadingMessage: " Loading OpenRouter's featured model catalog...", - retiredModelIds: [], - writeLine: log, - }); }); it("lets a same-gateway route constraint override the Deep Agents default before probing", async () => { diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts index 5fb9e5ecd5..6f24c6587c 100644 --- a/src/lib/onboard/setup-nim-flow.ts +++ b/src/lib/onboard/setup-nim-flow.ts @@ -7,7 +7,6 @@ import { resolveAgentProviderInferenceApi, } from "../inference/config"; import type { GatewayRouteDiscoveryConstraints } from "../inference/gateway-route-compatibility"; -import { OPENROUTER_FEATURED_MODELS_URL } from "../inference/openrouter"; import type { VllmProfile } from "../inference/vllm"; import { isBackToSelection } from "../navigation"; import type { HermesAuthMethod } from "./hermes-auth"; @@ -342,14 +341,7 @@ export function createSetupNim( defaultModel: resolveAgentDefaultCloudModel(agent), writeLine: deps.log, }); - const openRouterFeaturedModels = deps.createNvidiaFeaturedModelSession({ - catalogLabel: "OpenRouter's featured model catalog", - catalogUrl: OPENROUTER_FEATURED_MODELS_URL, - defaultModel: resolveAgentDefaultCloudModel(agent), - loadingMessage: " Loading OpenRouter's featured model catalog...", - retiredModelIds: [], - writeLine: deps.log, - }); + const openRouterFeaturedModels = nvidiaFeaturedModels; const createSelectionState = (): SetupNimSelectionState => { const state: SetupNimSelectionState = { model, diff --git a/test/onboard-openrouter-inference.test.ts b/test/onboard-openrouter-inference.test.ts index 576f763fe9..44ffa56347 100644 --- a/test/onboard-openrouter-inference.test.ts +++ b/test/onboard-openrouter-inference.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { SetupInference, SetupInferenceDeps } from "../src/lib/onboard/setup-inference.js"; import { createDirectSetupInferenceHarnessFactory, @@ -12,16 +12,33 @@ import { const onboard = require("../src/lib/onboard") as { createSetupInference: (overrides?: Partial) => SetupInference; }; +const openrouterRuntimeOnboard = + require("../src/lib/onboard/openrouter-runtime") as typeof import("../src/lib/onboard/openrouter-runtime.js"); const createDirectSetupInferenceHarness = createDirectSetupInferenceHarnessFactory( onboard.createSetupInference, ); describe("OpenRouter onboarding inference setup", () => { - it("configures OpenRouter through the remote provider setup branch (#5826)", async () => { + it("configures OpenRouter through the runtime header adapter (#5826)", async () => { await withProcessEnv({ OPENROUTER_API_KEY: "sk-or-test" }, async () => { + const ensureAdapter = vi.fn(async () => ({ + baseUrl: "http://host.openshell.internal:11437/v1", + localBaseUrl: "http://127.0.0.1:11437/v1", + credentialEnv: "OPENROUTER_API_KEY", + logPath: "/tmp/openrouter-runtime-adapter.log", + })); + const setupOpenRouterRuntimeInference = + openrouterRuntimeOnboard.setupOpenRouterRuntimeInference; const harness = createDirectSetupInferenceHarness({ - overrides: { isNonInteractive: () => true }, + overrides: { + isNonInteractive: () => true, + openrouterRuntimeOnboard: { + setupOpenRouterRuntimeInference: ( + input: Parameters[0], + ) => setupOpenRouterRuntimeInference({ ...input, ensureAdapter }), + }, + }, }); await harness.setupInference( @@ -32,11 +49,12 @@ describe("OpenRouter onboarding inference setup", () => { "OPENROUTER_API_KEY", ); + expect(ensureAdapter).toHaveBeenCalledWith({ authorizationToken: "sk-or-test" }); const commands = harness.commands.map(({ command }) => command); assert.deepEqual(commands, [ "provider get -g nemoclaw openrouter-api", - "provider update -g nemoclaw openrouter-api --credential OPENROUTER_API_KEY --config OPENAI_BASE_URL=https://openrouter.ai/api/v1", - "inference set -g nemoclaw --no-verify --provider openrouter-api --model moonshotai/kimi-k2.6", + "provider update -g nemoclaw openrouter-api --credential OPENROUTER_API_KEY --config OPENAI_BASE_URL=http://host.openshell.internal:11437/v1", + "inference set -g nemoclaw --no-verify --provider openrouter-api --model moonshotai/kimi-k2.6 --timeout 180", ]); assert.equal(harness.commands[1].env?.OPENROUTER_API_KEY, "sk-or-test"); assert.ok( @@ -51,13 +69,67 @@ describe("OpenRouter onboarding inference setup", () => { expect(harness.verifyOnboardInferenceSmoke).toHaveBeenCalledWith({ provider: "openrouter-api", model: "moonshotai/kimi-k2.6", - endpointUrl: "https://openrouter.ai/api/v1", + endpointUrl: "http://127.0.0.1:11437/v1", credentialEnv: "OPENROUTER_API_KEY", + forceOpenAiLike: true, }); assert.deepEqual(harness.errors, []); assert.deepEqual(harness.logs, [ + " OpenRouter Runtime adapter ready: sandbox route http://host.openshell.internal:11437/v1, host log /tmp/openrouter-runtime-adapter.log", + " ✓ Inference route set: openrouter-api / moonshotai/kimi-k2.6", + ]); + }); + }); + + it("updates OpenRouter adapter config while reusing a gateway-held credential (#5826)", async () => { + await withProcessEnv({ OPENROUTER_API_KEY: undefined }, async () => { + const ensureAdapter = vi.fn(async () => ({ + baseUrl: "http://host.openshell.internal:11437/v1", + localBaseUrl: "http://127.0.0.1:11437/v1", + credentialEnv: "OPENROUTER_API_KEY", + logPath: "/tmp/openrouter-runtime-adapter.log", + })); + const setupOpenRouterRuntimeInference = + openrouterRuntimeOnboard.setupOpenRouterRuntimeInference; + const harness = createDirectSetupInferenceHarness({ + overrides: { + isNonInteractive: () => true, + openrouterRuntimeOnboard: { + setupOpenRouterRuntimeInference: ( + input: Parameters[0], + ) => setupOpenRouterRuntimeInference({ ...input, ensureAdapter }), + }, + }, + }); + + await harness.setupInference( + "test-box", + "moonshotai/kimi-k2.6", + "openrouter-api", + "https://openrouter.ai/api/v1", + "OPENROUTER_API_KEY", + null, + [], + { + reuseGatewayCredentialWithoutLocalKey: true, + skipHostInferenceSmoke: true, + }, + ); + + expect(ensureAdapter).toHaveBeenCalledWith({ authorizationToken: null }); + expect(harness.commands.map(({ command }) => command)).toEqual([ + "provider get -g nemoclaw openrouter-api", + "provider update -g nemoclaw openrouter-api --config OPENAI_BASE_URL=http://host.openshell.internal:11437/v1", + "inference set -g nemoclaw --no-verify --provider openrouter-api --model moonshotai/kimi-k2.6 --timeout 180", + ]); + expect(harness.commands[1].env?.OPENROUTER_API_KEY).toBeUndefined(); + expect(harness.verifyOnboardInferenceSmoke).not.toHaveBeenCalled(); + expect(harness.logs).toEqual([ + " OpenRouter Runtime adapter ready: sandbox route http://host.openshell.internal:11437/v1, host log /tmp/openrouter-runtime-adapter.log", + " Reusing existing gateway credential; skipping host inference smoke.", " ✓ Inference route set: openrouter-api / moonshotai/kimi-k2.6", ]); + expect(harness.errors).toEqual([]); }); }); }); diff --git a/test/support/setup-inference-test-harness.ts b/test/support/setup-inference-test-harness.ts index b105af4dd6..c382dce124 100644 --- a/test/support/setup-inference-test-harness.ts +++ b/test/support/setup-inference-test-harness.ts @@ -322,6 +322,12 @@ export function createDirectSetupInferenceHarnessFactory( // bypass the selection phase that normally supplies validated pins. resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], promptValidationRecovery: async () => "selection", + bedrockRuntimeOnboard: { + setupBedrockRuntimeInference: async () => ({ handled: false as const }), + }, + openrouterRuntimeOnboard: { + setupOpenRouterRuntimeInference: async () => ({ handled: false as const }), + }, validateLocalProvider: () => ({ ok: true }), getLocalProviderHealthCheck: () => null, getLocalProviderBaseUrl: (provider: string) =>