/** * Probe implementations for BackendStatusRegistry. * * Two upstream shapes are supported in Phase B: * * 1. **Direct workers** (llama-server compatible). We hit `/slots` * and derive busySlots / totalSlots / loadedModel from the slot array. * Recent llama-server builds let operators disable /slots via * `--no-slots`; in that case the endpoint returns 404/405/501 and we * fall back to `/health`. That fallback is also the vLLM path: it * reads `/metrics`, so besides `online` it can report throughput and * — for vLLM / SGLang — the running-request count paired with the * worker's configured concurrency as the denominator. * `/metrics` の本文は 3 通りに読み分ける(llama-server / vLLM / * SGLang)。読み分けは `readBackendMetrics` を参照。 * * 2. **Proxy workers** (LiteLLM Proxy). We hit `/health` and * convert each healthy_endpoints / unhealthy_endpoints entry into a * per-deployment NodeStatus. Slot / model info isn't currently * surfaced by LiteLLM's /health; v1 reports `online` + cache hits and * leaves the rest null. Phase C can switch to `/metrics` or * `/model/info` once they're wired through the proxy auth surface. * * Both probes: * - apply a 3s per-request timeout via AbortController (cluster-wide * hangs would otherwise wedge the entire registry tick), * - send the worker's apiKey as a Bearer header so team-scoped tokens * work, * - normalize the endpoint by stripping trailing slashes and stripping * a single trailing `/v1` segment (the worker config typically points * at the OpenAI-shaped `…/v1` base; llama-server's `/slots` lives at * the server root, not under /v1). * * Long-form rationale lives in * docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md (Phase B). */ import type { WorkerDef } from '../config.js'; import type { NodeStatus, ProbeContext } from './backend-status-registry.js'; import { capProbeError, describeProbeError, redactEndpoints } from './probe-error.js'; import { createVllmRateTracker, isSglangMetricsPayload, isVllmMetricsPayload, parsePrometheusText, SGLANG_DIALECT, VLLM_DIALECT, type VllmMetricsSnapshot, type VllmRateTracker, } from './vllm-metrics.js'; import { forgetRoutesFor, isRouteKnownAbsent, noteRouteStatus, } from '../llm/absent-route-memo.js'; /** * Per-probe HTTP timeout. Default was 3s historically but llama-server * stalls its HTTP loop while a long-running generation is in flight on * its event loop, so `/slots` regularly comes back >3s on a busy GPU * and the registry would flip the node to offline mid-inference. 10s * gives the server-side enough headroom to interleave the probe with * an active generation without losing real liveness sensitivity (a * truly dead node still trips ECONNREFUSED / DNS failure inside the * window). 2026-05-21 dogfooding feedback. */ export const DEFAULT_PROBE_TIMEOUT_MS = 10_000; /** Trim trailing slashes and one optional `/v1` suffix so we can build sibling paths. */ export function normalizeWorkerBase(endpoint: string): string { const trimmed = endpoint.replace(/\/+$/, ''); return trimmed.endsWith('/v1') ? trimmed.slice(0, -3) : trimmed; } function authHeaders(apiKey: string | undefined): Record { const h: Record = { Accept: 'application/json' }; if (apiKey) h['Authorization'] = `Bearer ${apiKey}`; return h; } async function fetchWithTimeout( url: string, init: RequestInit, timeoutMs: number, fetchImpl: typeof fetch, externalSignal?: AbortSignal, ): Promise { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); // Chain the registry-wide cycle signal so shutdown can cancel pending // probes immediately instead of waiting for timeoutMs. let onExternalAbort: (() => void) | undefined; if (externalSignal) { if (externalSignal.aborted) { ctrl.abort(); } else { onExternalAbort = () => ctrl.abort(); externalSignal.addEventListener('abort', onExternalAbort, { once: true }); } } try { return await fetchImpl(url, { ...init, signal: ctrl.signal }); } finally { clearTimeout(t); if (externalSignal && onExternalAbort) { externalSignal.removeEventListener('abort', onExternalAbort); } } } export interface ProbeDeps { fetchImpl?: typeof fetch; timeoutMs?: number; now?: () => string; /** * 経過秒を測るための時計 (ms)。累積カウンターから速さを出すのに要る。 * テストが刻みを自分で決められるように差し込み口にしてある。 */ nowMs?: () => number; /** * 前回の scrape の値と時刻を持つ入れ物。既定では probe を組み立てた * ときに 1 つ作られ、その probe と寿命を共にする——**観測している側の * メモリに持つ**という決めごとの実体がこれ(理由は * engine/vllm-metrics.ts の冒頭)。 */ rateTracker?: VllmRateTracker; } // ── llama-server (direct worker) ─────────────────────────────────────────── /** * Parse a Prometheus text-format /metrics payload from llama-server and * extract a current decode throughput in tokens/sec. * * llama-server exposes several Prometheus metrics relevant to throughput: * - `llamacpp:n_decode_total` (counter) * - `llamacpp:n_prompt_tokens_total` (counter) * - `llamacpp:requests_processing` (gauge) * - `llamacpp:tokens_per_second` (gauge) — newer builds * - `llamacpp:prompt_tokens_seconds` (gauge) — older builds * * We prefer a directly-named tokens/sec gauge when present; otherwise we * fall back to `prompt_tokens_seconds` (also a gauge in /metrics). * Counter-based rate computation requires two scrapes and a per-scrape * delta — that's a Phase C+ enhancement; Phase C v1 only surfaces gauges. * * Exported for unit testing. Returns null when no recognised gauge is * present in the body. */ export function parseLlamaMetricsThroughput(text: string): number | null { if (typeof text !== 'string' || text.length === 0) return null; // Match `metric_name ` ignoring optional `{labels}` and // trailing timestamp. Anchored per-line, comment lines (`# HELP`/ // `# TYPE`) skipped. // // Order across candidates matters: the first recognised metric family // wins. tokens_per_second is the cleanest signal; prompt_tokens_seconds // is a fallback. Within a family we use the `g` flag so multi-label // exports (one llama-server process serving multiple slots/models with // distinct `{model="..."}` labels) are all collected — first-match // would silently drop sibling series and misreport the cluster's // throughput. We surface the max across labels so the widget shows // the fastest currently-decoding model instead of an arbitrary one. const candidates = [ /^llamacpp:tokens_per_second(?:\{[^}]*\})?\s+([0-9eE.+-]+)/gm, /^llamacpp:prompt_tokens_seconds(?:\{[^}]*\})?\s+([0-9eE.+-]+)/gm, /^llamacpp:n_decode_tokens_per_second(?:\{[^}]*\})?\s+([0-9eE.+-]+)/gm, ]; for (const re of candidates) { const values: number[] = []; for (const m of text.matchAll(re)) { const v = Number(m[1]); if (Number.isFinite(v) && v >= 0) values.push(v); } if (values.length > 0) return Math.max(...values); } return null; } /** * llama-server の gauge から prefill / decode を分けて読む (issue #1104)。 * * `parseLlamaMetricsThroughput` は触らない——あちらは既存の 1 本の表示を * 支えており、候補の優先順位を変えると出ていた数字が変わる。こちらは * 「どちらの速さか」がはっきりしている gauge だけを名指しで拾う。 * * **実在する名前**(llama.cpp 本流 `tools/server/server-context.cpp` の * `all_metrics_def`)は `llamacpp:prompt_tokens_seconds`(読み)と * `llamacpp:predicted_tokens_seconds`(生成)の 2 つ。`tokens_per_second` * と `n_decode_tokens_per_second` は本流に存在しないので、派生ビルド向けの * 予備として後ろに置くだけにする。 * * 返り値の 3 状態を区別すること。 * - キーが無い : その gauge の行が本文に 1 つも無い(=この相手は扱わない) * - `null` : 行はあるが読める値が無い、または「まだ材料が無い」の 0 * - 数値 : 実測値 * * ラベル違いの同名 gauge が複数あるときは、`parseLlamaMetricsThroughput` * と同じく最大値を採る。llama-server の 1 プロセスは 1 モデルを持つので、 * 複数系列はスロット別の内訳であり、その最大がいま出ている速さになる。 */ export function parseLlamaMetricsSplit(text: string): { decodeTps?: number | null; prefillTps?: number | null; } { if (typeof text !== 'string' || text.length === 0) return {}; /** * 1 つの gauge 名を読む。行が無ければ undefined。 * * llama.cpp はこの gauge に、**まだ 1 件も処理していないときちょうど 0** * を入れる(本流の実装が `n ? 1.e3 / t * n : 0.` と書いている)。実測で * ぴったり 0 tok/s になることはない——それは処理に無限の時間がかかった * という意味になる。だから 0 は測定値ではなく「平均する材料がまだ無い」 * の合図で、未計測(null)に倒す。ここを 0 のまま通すと、立ち上げ直後の * サーバーが「読み 0・生成 0」と表示され、この issue が塞いだはずの * 「未計測を 0 と書く」に逆戻りする。 */ const gaugeOf = (re: RegExp): number | null | undefined => { let seen = false; let best: number | undefined; for (const m of text.matchAll(re)) { seen = true; const v = Number(m[1]); if (!Number.isFinite(v) || v < 0) continue; if (best === undefined || v > best) best = v; } if (!seen) return undefined; return best !== undefined && best > 0 ? best : null; }; const firstPresent = (...res: RegExp[]): number | null | undefined => { for (const re of res) { const value = gaugeOf(re); if (value !== undefined) return value; } return undefined; }; const decodeTps = firstPresent( /^llamacpp:predicted_tokens_seconds(?:\{[^}]*\})?\s+([0-9eE.+-]+)/gm, /^llamacpp:tokens_per_second(?:\{[^}]*\})?\s+([0-9eE.+-]+)/gm, /^llamacpp:n_decode_tokens_per_second(?:\{[^}]*\})?\s+([0-9eE.+-]+)/gm, ); const prefillTps = firstPresent( /^llamacpp:prompt_tokens_seconds(?:\{[^}]*\})?\s+([0-9eE.+-]+)/gm, ); return { ...(decodeTps !== undefined ? { decodeTps } : {}), ...(prefillTps !== undefined ? { prefillTps } : {}), }; } /** * 1 ノードぶんの速さ・キャッシュの読み取り結果。 * * 読み取り項目は**キーの有無で「扱うかどうか」を表す**。キーが無いのは * 「この相手はそもそもその値を出さない/読み取れなかった」、`null` は * 「扱うが今回は測れなかった」。行に載せるときも同じ区別のまま渡すので、 * 画面は扱わない項目の欄そのものを出さずに済む。 */ export interface BackendMetricsReading extends Partial { /** 既存の 1 本の表示用。llama-server でのみ入り、vLLM では null のまま。 */ throughputTps: number | null; } /** 読み取りに失敗したときの姿。**読み取り項目のキーを 1 つも生やさない。** */ const EMPTY_METRICS_READING: BackendMetricsReading = { throughputTps: null }; const SPLIT_METRIC_FIELDS = ['prefillTps', 'decodeTps', 'cacheHitRate', 'kvCacheUsage'] as const; /** * 読み取り結果のうち、**扱うと分かった項目だけ**を行へ載せる形に落とす。 * * 失敗した読み取り(=キーが 1 つも無い)を素通しで代入すると、対応して * いない相手の行にも 4 つの「—」が恒久的に並ぶ。それは「対応していない * 接続先の表示は変わらない」という約束と食い違う。 */ export function splitMetricsFields( reading: BackendMetricsReading, ): Partial> { const out: Partial> = {}; for (const key of SPLIT_METRIC_FIELDS) { const value = reading[key]; if (value !== undefined) out[key] = value; } return out; } /** * 実行中のリクエスト本数。読めなかったときは null(未計測)。 * * キーが無い(対応していない相手)と null(今回読めなかった)は、行に * 載せる段では同じ「不明」に落ちる。0 に丸めると、動いている相手が * 空いているように見える。 */ export function resolveRunningRequests(reading: BackendMetricsReading): number | null { const value = reading.numRequestsRunning; if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null; return Math.round(value); } /** * 設定に書かれた同時実行数。書かれていなければ 0(=表示は伏せる)。 * * これは申告値であって検査値ではない。呼び出し側は必ず * `totalSlotsProbed: false` と組で使うこと。 */ export function resolveDeclaredSlots(worker: WorkerDef): number { const value = worker.maxConcurrency; if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 0; return Math.floor(value); } /** * `/metrics` の本文 1 つを、どちらのバックエンドのものか見分けて読む。 * * 見分けは本文そのもの——`vllm:` で始まる系列が 1 つでもあれば vLLM 用の * 経路、`sglang:` で始まる系列が 1 つでもあれば SGLang 用の経路に入る。 * 設定に「これは vLLM です」と書かせないのは、書き忘れた接続先が黙って * 未計測になるのを避けるため。どれとも読めない本文は llama-server の * 読み方に落ち、そこでも読めなければ null(未計測)で返る。 */ export function readBackendMetrics( text: string, targetKey: string, tracker: VllmRateTracker, observedAtMs: number, ): BackendMetricsReading { const samples = parsePrometheusText(text); if (isVllmMetricsPayload(samples)) { return { throughputTps: null, ...tracker.observe(targetKey, samples, observedAtMs, VLLM_DIALECT), }; } // SGLang も累計を 2 回読んで差を取る。前置きキャッシュの効きだけは // 累計 2 本ではなく比率の gauge から来る(出どころの違いは // engine/vllm-metrics.ts の `PrefixCacheSource` に書いてある)。 if (isSglangMetricsPayload(samples)) { return { throughputTps: null, ...tracker.observe(targetKey, samples, observedAtMs, SGLANG_DIALECT), }; } // llama-server は prefix cache のヒット率も KV cache の占有率も // Prometheus に出さない(本流の項目一覧に無いことを確認済み)。**扱わない // 値はキーごと出さない**——恒久的に「—」を並べると、利用者に存在しない // 設定を探させることになる。 const split = parseLlamaMetricsSplit(text); return { throughputTps: parseLlamaMetricsThroughput(text), ...split }; } /** 対応表に載っている `/metrics` の URL。無ければ null(未計測のまま)。 */ export function resolveBackendMetricsUrl( worker: WorkerDef, nodeId: string, fallbackBase: string | null, ): string | null { for (const entry of worker.backendMetrics ?? []) { if (entry && entry.nodeId === nodeId && typeof entry.url === 'string' && entry.url.length > 0) { return entry.url; } } return fallbackBase ? `${fallbackBase}/metrics` : null; } interface LlamaSlot { id?: number; // `is_processing` is the canonical field on recent llama-server builds. // Older builds called it `state` (0 = idle, 1 = processing). is_processing?: boolean; state?: number; model?: string; task_id?: number | string; } /** * Parse a llama-server `/slots` JSON payload into busy / total / model. * * Tolerates both the modern object-array shape and the older shape where * the response was already an envelope (`{ slots: [...] }`). Returns * conservative zeros if the payload doesn't look like a slot array. */ export function parseLlamaSlots(payload: unknown): { busySlots: number; totalSlots: number; loadedModel: string | null; } { let slots: LlamaSlot[] = []; if (Array.isArray(payload)) { slots = payload as LlamaSlot[]; } else if (payload && typeof payload === 'object') { const inner = (payload as { slots?: unknown }).slots; if (Array.isArray(inner)) slots = inner as LlamaSlot[]; } let busy = 0; let model: string | null = null; for (const s of slots) { const isBusy = s.is_processing === true || (typeof s.state === 'number' && s.state !== 0); if (isBusy) busy++; if (!model && typeof s.model === 'string' && s.model.length > 0) model = s.model; } return { busySlots: busy, totalSlots: slots.length, loadedModel: model }; } /** * Probe a direct (llama-server) worker. * * Tries `/slots` first; if the server rejects it (--no-slots or older * builds), falls back to `/health` so we can still report online/offline. */ export function buildDirectProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?: ProbeContext) => Promise { const fetchImpl = deps.fetchImpl ?? fetch; const timeoutMs = deps.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; const now = deps.now ?? (() => new Date().toISOString()); const nowMs = deps.nowMs ?? (() => Date.now()); // 観測している側のメモリ。この probe を組み立てた 1 つのプロセスに閉じる。 // プロセスを立て直すと空になり、立て直した直後の 1 回は基準を取るだけで // 速さを出さない(null であって 0 ではない)。 const rateTracker = deps.rateTracker ?? createVllmRateTracker(); return async function probeDirect(worker: WorkerDef, ctx?: ProbeContext): Promise { // Frozen before the first request leaves. `lastSeen` is stamped once the // body has been read (and, on the /slots path, after a second round trip // to /metrics), so it can land after a backend swap that happened // mid-probe and make a row built from the OLD backend look newer than the // new one. See NodeStatus.probeStartedAt. const probeStartedAt = now(); const base = normalizeWorkerBase(worker.endpoint); // Security: direct probes (llama-server compatible) must NOT receive the // worker.apiKey. That key is a LiteLLM virtual key intended for the // proxy auth surface — forwarding it to a direct llama-server would // leak the secret to upstream access logs in plaintext. Only the proxy // probe path (buildProxyProbe) sends Authorization. const headers = authHeaders(undefined); const slotsUrl = `${base}/slots`; // llama-server 固有の口。相手が持っていないと分かっている間は叩かず、 // そのまま /health の経路へ回す(issue #1172。この probe は 5 秒ごとに // 走るので、vLLM を指していると 404 が相手の記録を最も速く埋める)。 if (isRouteKnownAbsent(base, 'GET /slots', { nowMs })) { return probeHealthFallback( base, headers, worker, timeoutMs, fetchImpl, now, probeStartedAt, rateTracker, nowMs, ctx?.signal, ); } let slotsRes: Response; try { slotsRes = await fetchWithTimeout(slotsUrl, { method: 'GET', headers }, timeoutMs, fetchImpl, ctx?.signal); } catch (err) { // Network-level failure (DNS, refused, timeout). The server may // still be alive but we can't talk to it; record online=false. // 接続先の入れ替えは再起動を伴うので、ここで口の記憶を捨てて // 次の周回で叩き直す。 forgetRoutesFor(base); return errorStatus(worker, 'direct', err, now(), probeStartedAt); } noteRouteStatus(base, 'GET /slots', slotsRes.status, { nowMs }); if (slotsRes.ok) { let body: unknown; try { body = await slotsRes.json(); } catch (err) { return errorStatus(worker, 'direct', err, now(), probeStartedAt); } const parsed = parseLlamaSlots(body); // /metrics is a best-effort enrichment: a failure here must NOT // demote the status to offline. Some llama-server builds disable // /metrics by default (`--metrics` opt-in) or run behind a proxy // that strips Prometheus endpoints. // 基準は「ノード+実際に叩いた URL」で分ける。対応表で読み先を // 差し替えたとき、古い読み先の累計から差を取ると「測れた」うその // 速さが 1 回出る。worker.endpoint で代用すると差し替えを見逃す。 const metricsUrl = resolveBackendMetricsUrl(worker, worker.id, base); const metrics = await tryFetchBackendMetrics( metricsUrl, `${worker.id}|${metricsUrl ?? ''}`, rateTracker, nowMs, headers, timeoutMs, fetchImpl, ctx?.signal, ); return { nodeId: worker.id, workerId: worker.id, source: 'direct', online: true, busy: parsed.busySlots > 0, busySlots: parsed.busySlots, totalSlots: parsed.totalSlots, // /slots を実際に読んだ値。直結の probe は申告値を代入しない。 totalSlotsProbed: true, loadedModel: parsed.loadedModel ?? worker.model ?? null, throughputTps: metrics.throughputTps, ...splitMetricsFields(metrics), lastSeen: now(), probeStartedAt, probedEndpoint: worker.endpoint, }; } // /slots disabled (--no-slots) returns 404 on most builds and 501 on // some forks. /health is the documented liveness endpoint and is // always available. vLLM lands here too: it has no /slots at all, so // this is the path its metrics arrive on. if (slotsRes.status === 404 || slotsRes.status === 405 || slotsRes.status === 501) { return probeHealthFallback( base, headers, worker, timeoutMs, fetchImpl, now, probeStartedAt, rateTracker, nowMs, ctx?.signal, ); } return errorStatus( worker, 'direct', new Error(`/slots returned HTTP ${slotsRes.status}`), now(), probeStartedAt, ); }; } /** * Best-effort metrics fetch. Returns all-null on any failure (404 from * builds without --metrics, no configured URL, network error, parse * failure) so the primary /slots- or /health-derived status is unaffected. * * Uses the same per-request timeout cap as /slots so a hung /metrics * endpoint can't double the worst-case probe latency. * * `headers` は呼び出し側が組み立てたものをそのまま使う。直結の probe は * Authorization を持たない(`authHeaders(undefined)`)ので、ワーカーの * 資格情報がバックエンドへ出ることはない。 */ async function tryFetchBackendMetrics( url: string | null, targetKey: string, tracker: VllmRateTracker, nowMs: () => number, headers: Record, timeoutMs: number, fetchImpl: typeof fetch, externalSignal?: AbortSignal, ): Promise { if (!url) return EMPTY_METRICS_READING; try { const res = await fetchWithTimeout( url, { method: 'GET', headers: { ...headers, Accept: 'text/plain' } }, timeoutMs, fetchImpl, externalSignal, ); if (!res.ok) return EMPTY_METRICS_READING; const text = await res.text(); return readBackendMetrics(text, targetKey, tracker, nowMs()); } catch { return EMPTY_METRICS_READING; } } async function probeHealthFallback( base: string, headers: Record, worker: WorkerDef, timeoutMs: number, fetchImpl: typeof fetch, now: () => string, /** Frozen by the caller before its first request left — see NodeStatus.probeStartedAt. */ probeStartedAt: string, tracker: VllmRateTracker, nowMs: () => number, externalSignal?: AbortSignal, ): Promise { try { const res = await fetchWithTimeout(`${base}/health`, { method: 'GET', headers }, timeoutMs, fetchImpl, externalSignal); if (!res.ok) { return errorStatus( worker, 'direct', new Error(`/health returned HTTP ${res.status}`), now(), probeStartedAt, ); } // vLLM は /slots を持たないのでこの経路に落ちてくる。ここで /metrics を // 読まないと、vLLM は永久に未計測のままになる。llama-server の --no-slots // 運用でも同じ経路を通り、これまで空だった速さの欄が埋まる。 const metricsUrl = resolveBackendMetricsUrl(worker, worker.id, base); const metrics = await tryFetchBackendMetrics( metricsUrl, `${worker.id}|${metricsUrl ?? ''}`, tracker, nowMs, headers, timeoutMs, fetchImpl, externalSignal, ); // 実行中の本数。vLLM の `/metrics` が出していれば入る。出していない // 相手(llama-server の --no-slots 等)では null のままで、その場合は // 従来どおり 0/0 ——「不明」を 0 本と書かない。 const running = resolveRunningRequests(metrics); const declaredSlots = running === null ? 0 : resolveDeclaredSlots(worker); return { nodeId: worker.id, workerId: worker.id, source: 'direct', online: true, busy: running !== null && running > 0, busySlots: running ?? 0, // 分母は設定に書かれた同時実行数で、**検査して数えたレーン数ではない**。 // 表示にはこれで足りるが、計測(total_slots 列)に採られると申告値が // 実測として残ってしまうので、検査済みとは名乗らない。 // /metrics が読めなかった行(--no-slots 等)はここも 0 のままで、 // 表示側の「分母 > 0 のときだけ描く」に任せて伏せる。 totalSlots: declaredSlots, totalSlotsProbed: false, loadedModel: worker.model ?? null, throughputTps: metrics.throughputTps, ...splitMetricsFields(metrics), lastSeen: now(), probeStartedAt, probedEndpoint: worker.endpoint, }; } catch (err) { // /slots と同じ扱い。接続そのものに失敗したら口の記憶を捨てる // (記憶で /slots を飛ばしている間も、ここが覚え直しの合図になる)。 forgetRoutesFor(base); return errorStatus(worker, 'direct', err, now(), probeStartedAt); } } // ── 外部のサービス (external_api worker) ─────────────────────────────────── /** * 資格情報が拒まれたときの理由。**「応答なし」と混ぜない** (issue #1152)。 * * 401 と 403 は「相手が落ちている」ではなく「こちらの鍵が通らない」。同じ * 灰色の丸印でも、直す場所がまったく違う——前者は相手を待つしかないが、 * 後者は設定画面で鍵を入れ直せば直る。文面でそこを言い切る。 * * `describeProbeError` を通さず、ここで文を組み立てて `lastProbeError` に * 入れる。相手の本文は 1 文字も混ぜない(宛先や鍵の断片が載る余地を作らない)。 */ export function externalApiAuthFailureMessage(status: number): string { return `API key rejected by the external service (HTTP ${status}) — check the worker's API key`; } /** * 外部のサービス(事業者が運用する OpenAI 互換 API)の生存確認。 * * 叩くのは `/v1/models` **1 本だけ**。互換仕様が定めている唯一の一覧の口で、 * 主要なサービスはどれも持っている。`/slots` も `/health` も持たないので、 * 直結向けの経路では 404 が 2 回返って必ず「応答なし」になっていた。 * * **資格情報を送る。**外部のサービスは鍵が無いと何も答えない。直結の経路が * 鍵を送らないのは自前のサーバーのアクセス記録に平文で残るのを避けるためで、 * 相手が認証を前提にしている外部のサービスにはその理由が当てはまらない。 * どちらを送るかは**設定に書かれた種別だけ**で決める(叩いて当たりを探さない)。 * * `/metrics` は読みに行かない。外部のサービスは Prometheus の口を持たない * うえ、毎回 1 往復を空振りさせるのは相手の記録を汚すだけになる。 */ export function buildExternalApiProbe( deps: ProbeDeps = {}, ): (worker: WorkerDef, ctx?: ProbeContext) => Promise { const fetchImpl = deps.fetchImpl ?? fetch; const timeoutMs = deps.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; const now = deps.now ?? (() => new Date().toISOString()); return async function probeExternalApi(worker: WorkerDef, ctx?: ProbeContext): Promise { const probeStartedAt = now(); const base = normalizeWorkerBase(worker.endpoint); const headers = authHeaders(worker.apiKey); const modelsUrl = `${base}/v1/models`; let res: Response; try { res = await fetchWithTimeout(modelsUrl, { method: 'GET', headers }, timeoutMs, fetchImpl, ctx?.signal); } catch (err) { return errorStatus(worker, 'direct', err, now(), probeStartedAt); } if (res.status === 401 || res.status === 403) { // 本文は読まない。相手のエラー本文には鍵の断片や組織名が載ることがある。 await res.body?.cancel().catch(() => { /* 読み捨てに失敗しても判定は変わらない */ }); return offlineStatus( worker, externalApiAuthFailureMessage(res.status), now(), probeStartedAt, ); } if (!res.ok) { return errorStatus( worker, 'direct', new Error(`/v1/models returned HTTP ${res.status}`), now(), probeStartedAt, ); } return { nodeId: worker.id, workerId: worker.id, source: 'direct', online: true, busy: false, busySlots: 0, // 外部のサービスはレーンという考え方を出さない。0 は「不明」であって // 「0 レーン」ではないので、検査済みとは名乗らない。 totalSlots: 0, totalSlotsProbed: false, loadedModel: worker.model ?? null, throughputTps: null, lastSeen: now(), probeStartedAt, probedEndpoint: worker.endpoint, }; }; } // ── LiteLLM (proxy worker) ───────────────────────────────────────────────── /** * Single LiteLLM `/health` entry. The exact field names depend on the * LiteLLM version; we treat all fields as optional and only consume the * ones we recognise. `model` is the deployment alias; some versions * nest it under `litellm_params.model`. */ interface LiteLLMHealthEntry { model?: unknown; litellm_params?: { model?: unknown } | null; cache?: unknown; error?: unknown; /** * AAO Gateway extension fields (additive — vanilla LiteLLM omits them * and we treat absence as "unknown busy state", same as before this * extension existed). When present, parseLiteLLMHealth populates the * NodeStatus with the gateway-aggregated busy view so every AAO * client pointed at the same gateway sees consistent backend usage. */ aao_busy_slots?: unknown; aao_total_slots?: unknown; aao_total_slots_probed?: unknown; aao_saturated?: unknown; aao_last_seen?: unknown; } interface LiteLLMHealthBody { healthy_endpoints?: LiteLLMHealthEntry[]; unhealthy_endpoints?: LiteLLMHealthEntry[]; // Some LiteLLM versions return `healthy_count` etc.; we don't need them // since we walk the arrays directly. [key: string]: unknown; } /** * Coerce an arbitrary `aao_*_slots` value into a non-negative integer. * Floors fractional inputs, drops anything non-finite or negative to * zero so a malformed entry never corrupts the registry. */ function coerceNonNegInt(v: unknown): number { if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return 0; return Math.floor(v); } function extractDeploymentId(entry: LiteLLMHealthEntry): string | null { if (typeof entry.model === 'string' && entry.model.length > 0) return entry.model.trim(); const nested = entry.litellm_params?.model; if (typeof nested === 'string' && nested.length > 0) return nested.trim(); return null; } /** * Coerce a LiteLLM `error` field to a human-readable string. * * LiteLLM pre-1.40 surfaced errors as plain strings, but recent * versions (post-1.40) wrap them in `{ message: string, type: string }` * objects. The previous string-only guard silently dropped the object * form, leaving the widget with "unhealthy but reason unknown" — a * red icon with nothing to action. * * Strategy: * - string → return as-is * - object with `.message` → return that * - other object → JSON.stringify so something useful surfaces in * logs / UI (better than the cryptic "[object Object]") * - null/undefined → undefined (caller skips the field) */ function extractErrorMessage(err: unknown): string | undefined { // LiteLLM の error は `api_base` を本文に含めてくることがあるので、 // どの形で来ても宛先を伏せてから返す(issue #1128)。 // // **長さも必ず切る。**上流が本文をまるごと返してくることがあり、実測で // 2015 文字が素通りしていた。この文字列は画面の title と REST、さらに // 自分が gateway なら `/health` にも載るので、`describeProbeError` と // 同じ上限をここでも掛ける。 const clean = (text: string): string => capProbeError(redactEndpoints(text)); if (typeof err === 'string') return clean(err); if (err && typeof err === 'object') { const obj = err as { message?: unknown }; if (typeof obj.message === 'string') return clean(obj.message); try { return clean(JSON.stringify(err)); } catch { return clean(String(err)); } } return undefined; } /** * 相手の `/health` が LiteLLM の形で配下を名乗ったか。 * * 本文が読めなかった(vLLM の 0 バイト応答)ときと、読めたが LiteLLM の形 * ではなかった(llama-server の `{"status":"ok"}`)ときは、どちらも「配下が * 何台いるか分からない」であって「配下が 0 台」ではない。区別しないと、 * 生きている相手が灰色の Offline のまま置き去りになる(issue #1128)。 */ function isLiteLLMShaped(body: unknown): boolean { if (!body || typeof body !== 'object') return false; const obj = body as { healthy_endpoints?: unknown; unhealthy_endpoints?: unknown }; return Array.isArray(obj.healthy_endpoints) || Array.isArray(obj.unhealthy_endpoints); } /** * Convert a LiteLLM /health response into one NodeStatus per deployment. * Exported for unit-testing without a live proxy. * * Dedup policy (Phase C): **unhealthy wins precedence**. When the same * deployment id appears in both `healthy_endpoints` and * `unhealthy_endpoints` (a flapping backend), we surface the unhealthy * row so the widget doesn't mask a degraded state behind a green icon. * The error message from the unhealthy entry is preserved in * `lastProbeError`. Phase B used "first appearance wins" which silently * hid flap conditions from operators. */ export function parseLiteLLMHealth( body: unknown, workerId: string, ts: string, ): NodeStatus[] { const obj = (body && typeof body === 'object' ? body : {}) as LiteLLMHealthBody; const out: NodeStatus[] = []; const seen = new Set(); const push = (entry: LiteLLMHealthEntry, online: boolean): void => { const id = extractDeploymentId(entry); if (!id || seen.has(id)) return; seen.add(id); // AAO Gateway extension: when the gateway annotates each entry // with aao_busy_slots / aao_total_slots, we inherit its // multi-client-aggregated view. Without these fields (vanilla // LiteLLM or older gateway) busy stays 0 — same as before the // extension existed. const busySlots = coerceNonNegInt(entry.aao_busy_slots); const totalSlots = coerceNonNegInt(entry.aao_total_slots); // 相手が「検査して得た値だ」と明示したときだけ真。相手の検査が失敗すると // 相手は設定の申告値を配ってくるので、その値を検査済みとして扱うと、 // 接続できなかった backend のレーン数が計測に焼き込まれる。 // 未指定(古いゲートウェイ・素の LiteLLM)は「分からない」= false。 const totalSlotsProbed = entry.aao_total_slots_probed === true; out.push({ nodeId: id, workerId, source: 'proxy', online, busy: busySlots > 0, busySlots, totalSlots, totalSlotsProbed, loadedModel: id, throughputTps: null, lastSeen: ts, lastProbeError: online ? undefined : extractErrorMessage(entry.error), }); }; // Iterate unhealthy first so a flapping deployment registers as // unhealthy and the healthy entry's subsequent push is skipped by the // `seen` guard. for (const entry of obj.unhealthy_endpoints ?? []) push(entry, false); for (const entry of obj.healthy_endpoints ?? []) push(entry, true); return out; } /** * Probe a LiteLLM proxy worker. * * Returns one NodeStatus per deployment the proxy reports. If the proxy * itself is unreachable we synthesize a single offline NodeStatus keyed * to the workerId so the widget can render a "proxy down" row. */ export function buildProxyProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?: ProbeContext) => Promise { const fetchImpl = deps.fetchImpl ?? fetch; const timeoutMs = deps.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; const now = deps.now ?? (() => new Date().toISOString()); const nowMs = deps.nowMs ?? (() => Date.now()); const rateTracker = deps.rateTracker ?? createVllmRateTracker(); /** * 対応表に載っているバックエンドだけ `/metrics` を読む。 * * **ワーカーの資格情報は絶対に送らない。**あれはゲートウェイの認証面に * 向けて発行されたもので、実機へ横流しすると上流のアクセスログに平文で * 残る。ここで組み立てるヘッダーは Accept だけ。 */ const enrich = async ( status: NodeStatus, w: WorkerDef, signal?: AbortSignal, ): Promise => { const url = resolveBackendMetricsUrl(w, status.nodeId, null); if (!url) return status; const metrics = await tryFetchBackendMetrics( url, `${w.id}|${status.nodeId}|${url}`, rateTracker, nowMs, { Accept: 'application/json' }, timeoutMs, fetchImpl, signal, ); return { ...status, throughputTps: status.throughputTps ?? metrics.throughputTps, ...splitMetricsFields(metrics), }; }; return async function probeProxy(worker: WorkerDef, ctx?: ProbeContext): Promise { // Frozen before the request leaves, for the reason spelled out on // NodeStatus.probeStartedAt: `ts` below is taken after `await res.json()`, // so a mount that bounces while the body is being parsed would otherwise // hand the OLD backend list a timestamp newer than the new mount's start. const probeStartedAt = now(); const base = normalizeWorkerBase(worker.endpoint); const headers = authHeaders(worker.apiKey); const url = `${base}/health`; let res: Response; try { res = await fetchWithTimeout(url, { method: 'GET', headers }, timeoutMs, fetchImpl, ctx?.signal); } catch (err) { return [errorStatus(worker, 'proxy', err, now(), probeStartedAt)]; } if (!res.ok) { return [errorStatus( worker, 'proxy', new Error(`/health returned HTTP ${res.status}`), now(), probeStartedAt, )]; } // 2xx が返っている=上流は生きている。**本文が LiteLLM の形で読めない // だけでオフラインにはしない**(issue #1128)。vLLM の /health は 200 を // 返すが本文が 0 バイトで、空文字を JSON として読むと必ず例外になる。 // 以前はその例外がそのままオフラインの理由になっていたので、ゲートウェイ // 扱いで vLLM を指すと生きていても必ずオフラインだった。 // // 読めない本文は「配下を 1 件も報告しなかった」と同じ扱いにして、下の // 単独行(生きているが配下不明)へ寄せる。 let body: unknown = null; try { body = await res.json(); } catch { body = null; } const ts = now(); const parsed = parseLiteLLMHealth(body, worker.id, ts); if (parsed.length === 0) { // Proxy is alive but reports zero deployments: surface a single // synthetic row so the widget shows the proxy itself rather than // a blank panel. return [{ nodeId: worker.id, workerId: worker.id, source: 'proxy', online: true, busy: false, busySlots: 0, totalSlots: 0, loadedModel: null, throughputTps: null, lastSeen: ts, probeStartedAt, probedEndpoint: worker.endpoint, // 「配下が 0 件」と「配下を読めなかった」は別物。読めなかったほうは // 画面にそう出す(issue #1128)。読めた結果 0 件なら旗は立てない。 ...(isLiteLLMShaped(body) ? {} : { backendsUnknown: true }), }]; } // parseLiteLLMHealth reads only the /health body, which never names the // URL it came from; the probe stamps it here so a row can later be shown // to describe the server this call dialled. const stamped = parsed.map(status => ({ ...status, probeStartedAt, probedEndpoint: worker.endpoint })); // 対応表が空なら 1 本も追加の往復をしない=これまでと同じ挙動。 if (!worker.backendMetrics || worker.backendMetrics.length === 0) return stamped; return Promise.all(stamped.map(status => enrich(status, worker, ctx?.signal))); }; } function errorStatus( worker: WorkerDef, source: 'direct' | 'proxy', err: unknown, ts: string, probeStartedAt: string, ): NodeStatus { // 宛先を伏せ、cause まで辿った 1 行にしてから記録する。ここから先 // (registry・REST・gateway の /health)は素通しでよくなる。 return offlineStatus(worker, describeProbeError(err), ts, probeStartedAt, source); } /** * 応答なしの 1 行を、**理由の文をこちらで決めて**組み立てる。 * * 例外から起こす経路 (`errorStatus`) と、状態番号から自分の文面を当てる経路 * (外部のサービスの 401 / 403)で行の形を揃えるための土台。 */ function offlineStatus( worker: WorkerDef, reason: string, ts: string, probeStartedAt: string, source: 'direct' | 'proxy' = 'direct', ): NodeStatus { return { nodeId: worker.id, workerId: worker.id, source, online: false, busy: false, busySlots: 0, totalSlots: 0, loadedModel: null, throughputTps: null, lastSeen: ts, probeStartedAt, probedEndpoint: worker.endpoint, lastProbeError: reason, }; }