diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d60ea0b0..57307701a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes +- **fix(headroom):** detect a python interpreter managed by **mise / pyenv / asdf / conda**. Headroom's python probe (`src/lib/headroom/detect.ts`) searched a hardcoded `PATH`, but version managers expose their interpreters via shim dirs that only join `PATH` through interactive-shell activation — which the non-interactive server never runs, so a managed python (≥3.10) was invisible and Headroom reported it missing. The search path now prepends the well-known shim/bin dirs (`~/.local/share/mise/shims`, `~/.pyenv/shims`, `~/.asdf/shims`, `$CONDA_PREFIX/bin`, `~/.local/bin`, respecting `MISE_DATA_DIR`/`PYENV_ROOT`/`ASDF_DATA_DIR` when set), and a new `HEADROOM_PYTHON` env override lets operators point straight at their interpreter (mirroring `HEADROOM_URL`). Still shell-free (`execFileSync`). Regression guard: `tests/unit/headroom-detect.test.ts` (5). (thanks @loopyd) - **fix(executors):** strip the OpenAI-Codex/Claude-CLI `client_metadata` passthrough field for **NVIDIA** requests. NVIDIA's OpenAI-compatible wrapper rejects it with `400 Unsupported parameter`, the same class already handled for `cerebras`/`mistral`; `nvidia` (executor `default`) was missing from the strip allowlist so Codex/Claude-Code passthrough requests 400'd. Regression guard: `tests/unit/executor-default-strip-client-metadata.test.ts` (+nvidia case). (thanks @phidinhmanh) - **fix(translator):** strip the Claude-style `thinking` field for **NVIDIA `z-ai/glm-5.2`**. NVIDIA's OpenAI-compatible wrapper 400s on `thinking` (a Claude-format client routed here leaves a `thinking:{type:"adaptive"}`); the existing strip rule only dropped `reasoning`. Same class already handled for `minimax-m2.7`. Regression guard: `tests/unit/nvidia-minimax-thinking-strip.test.ts` (+glm-5.2 case). (thanks @phidinhmanh) - **fix(translator):** suppress the streamed `` close marker for the **Antigravity IDE** client. On thinking-only turns Antigravity rendered a bare `` as the sole visible content, tripping its loop-detection and wasting requests. Antigravity's UA (`vscode/ (Antigravity/)`) is added to the marker-suppress allowlist (alongside OpenCode); Claude Code / Cursor still get the marker, and `x-omniroute-thinking-marker: on` force-restores it. Regression guard: `tests/unit/think-close-marker-suppress-5245.test.ts`. (thanks @abdofallah) diff --git a/src/lib/headroom/detect.ts b/src/lib/headroom/detect.ts index cbdd9dd240..7952908e4a 100644 --- a/src/lib/headroom/detect.ts +++ b/src/lib/headroom/detect.ts @@ -18,9 +18,8 @@ import { execFileSync } from "node:child_process"; const EXTRA_BINS = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"]; -const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(":"); -const PYTHON_CANDIDATES = [ +export const PYTHON_CANDIDATES = [ "python3.13", "python3.12", "python3.11", @@ -28,6 +27,52 @@ const PYTHON_CANDIDATES = [ "python3", "python", ]; + +type EnvLike = Record; + +/** + * Build the PATH used to locate a python interpreter (upstream 9router#2353). + * + * Version managers (mise, pyenv, asdf) and conda expose their interpreters via + * shim dirs that are only added to PATH by interactive-shell activation + * (`eval "$(mise activate bash)"` in `.bashrc`). The non-interactive server + * process never runs that, so it would only ever see the system python. We + * therefore prepend the well-known shim/bin dirs (respecting the managers' own + * root env vars when set) so `findPython310` can discover a managed interpreter. + * + * Pure + env-injectable so it is unit-testable without spawning. + */ +export function buildPythonSearchPath(env: EnvLike = process.env): string { + const home = env.HOME || ""; + const managerDirs: string[] = []; + if (home || env.MISE_DATA_DIR) { + managerDirs.push(`${env.MISE_DATA_DIR || `${home}/.local/share/mise`}/shims`); + } + if (home || env.PYENV_ROOT) { + managerDirs.push(`${env.PYENV_ROOT || `${home}/.pyenv`}/shims`); + } + if (home || env.ASDF_DATA_DIR) { + managerDirs.push(`${env.ASDF_DATA_DIR || `${home}/.asdf`}/shims`); + } + if (env.CONDA_PREFIX) { + managerDirs.push(`${env.CONDA_PREFIX}/bin`); + } + if (home) { + managerDirs.push(`${home}/.local/bin`); // pipx / uv installs + } + return [...managerDirs, ...EXTRA_BINS, env.PATH || ""].filter(Boolean).join(":"); +} + +/** + * Resolve the ordered list of python commands to probe. A `HEADROOM_PYTHON` + * override (absolute path or command name) is tried first, mirroring the + * `HEADROOM_URL` escape hatch, so operators on exotic setups can point directly + * at their interpreter (upstream 9router#2353). + */ +export function resolvePythonCandidates(env: EnvLike = process.env): string[] { + const override = env.HEADROOM_PYTHON?.trim(); + return override ? [override, ...PYTHON_CANDIDATES] : [...PYTHON_CANDIDATES]; +} const MIN_VERSION: readonly [number, number] = [3, 10]; const HEADROOM_HEALTH_TIMEOUT_MS = 1500; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]); @@ -106,7 +151,7 @@ export function findHeadroomBinary(): string | null { const out = execFileSync("which", ["headroom"], { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, - env: { ...process.env, PATH: EXTENDED_PATH }, + env: { ...process.env, PATH: buildPythonSearchPath() }, }) .toString() .trim(); @@ -117,14 +162,16 @@ export function findHeadroomBinary(): string | null { } export function findPython310(): string | null { - for (const candidate of PYTHON_CANDIDATES) { + const searchPath = buildPythonSearchPath(); + for (const candidate of resolvePythonCandidates()) { try { - // candidate is from a fixed allowlist (PYTHON_CANDIDATES) above — no - // user input — but use execFileSync anyway to remove the shell entirely. + // candidate is from a fixed allowlist (PYTHON_CANDIDATES) plus an optional + // operator-set HEADROOM_PYTHON override — never remote/request input — but + // use execFileSync anyway to remove the shell entirely. const ver = execFileSync(candidate, ["--version"], { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, - env: { ...process.env, PATH: EXTENDED_PATH }, + env: { ...process.env, PATH: searchPath }, }) .toString() .trim(); diff --git a/tests/unit/headroom-detect.test.ts b/tests/unit/headroom-detect.test.ts new file mode 100644 index 0000000000..1978c59072 --- /dev/null +++ b/tests/unit/headroom-detect.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + buildPythonSearchPath, + resolvePythonCandidates, + PYTHON_CANDIDATES, +} from "../../src/lib/headroom/detect.ts"; + +// Regression guard for upstream 9router#2353 — Headroom could not detect a +// python interpreter managed by mise / pyenv / conda because those tools only +// add their shim dirs to PATH via interactive-shell activation, which the +// non-interactive server process never runs. + +test("buildPythonSearchPath includes env-manager shim dirs derived from HOME", () => { + const path = buildPythonSearchPath({ HOME: "/home/dev", PATH: "/usr/bin" }); + const segments = path.split(":"); + assert.ok(segments.includes("/home/dev/.local/share/mise/shims"), "mise shims"); + assert.ok(segments.includes("/home/dev/.pyenv/shims"), "pyenv shims"); + assert.ok(segments.includes("/home/dev/.asdf/shims"), "asdf shims"); + assert.ok(segments.includes("/home/dev/.local/bin"), "user local bin (pipx/uv)"); + // still preserves the caller's PATH and the classic EXTRA_BINS + assert.ok(segments.includes("/usr/bin"), "inherited PATH preserved"); + assert.ok(segments.includes("/opt/homebrew/bin"), "homebrew bin preserved"); +}); + +test("buildPythonSearchPath honors CONDA_PREFIX and custom manager roots", () => { + const path = buildPythonSearchPath({ + HOME: "/home/dev", + CONDA_PREFIX: "/opt/conda/envs/ml", + PYENV_ROOT: "/custom/pyenv", + MISE_DATA_DIR: "/custom/mise", + }); + const segments = path.split(":"); + assert.ok(segments.includes("/opt/conda/envs/ml/bin"), "active conda env bin"); + assert.ok(segments.includes("/custom/pyenv/shims"), "PYENV_ROOT override"); + assert.ok(segments.includes("/custom/mise/shims"), "MISE_DATA_DIR override"); +}); + +test("buildPythonSearchPath is robust when HOME/PATH are absent", () => { + const path = buildPythonSearchPath({}); + const segments = path.split(":").filter(Boolean); + // no empty segments, still returns the static EXTRA_BINS + assert.ok(segments.includes("/usr/bin")); + assert.ok(!path.includes("::"), "no empty path segments"); +}); + +test("resolvePythonCandidates puts HEADROOM_PYTHON override first", () => { + const candidates = resolvePythonCandidates({ HEADROOM_PYTHON: "/opt/py/bin/python" }); + assert.equal(candidates[0], "/opt/py/bin/python"); + // default candidates still follow + assert.ok(candidates.includes("python3")); +}); + +test("resolvePythonCandidates returns the default list when no override", () => { + const candidates = resolvePythonCandidates({}); + assert.deepEqual(candidates, [...PYTHON_CANDIDATES]); +});