mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
fix(headroom): detect python managed by mise/pyenv/asdf/conda (port from 9router#2353) (#6382)
detect python managed by mise/pyenv/asdf/conda (port #2353) (net +1/-0, test OK). Integrated into release/v3.8.46.
This commit is contained in:
committed by
GitHub
parent
bd65eeb045
commit
259d9b0f38
@@ -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 `</think>` close marker for the **Antigravity IDE** client. On thinking-only turns Antigravity rendered a bare `</think>` as the sole visible content, tripping its loop-detection and wasting requests. Antigravity's UA (`vscode/<v> (Antigravity/<v>)`) 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)
|
||||
|
||||
@@ -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<string, string | undefined>;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
58
tests/unit/headroom-detect.test.ts
Normal file
58
tests/unit/headroom-detect.test.ts
Normal file
@@ -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]);
|
||||
});
|
||||
Reference in New Issue
Block a user