Files
maestro/src/engine/backend-probes.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

1041 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Probe implementations for BackendStatusRegistry.
*
* Two upstream shapes are supported in Phase B:
*
* 1. **Direct workers** (llama-server compatible). We hit `<endpoint>/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 `<endpoint>/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<string, string> {
const h: Record<string, string> = { 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<Response> {
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 <number>` 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<VllmMetricsSnapshot> {
/** 既存の 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<Pick<NodeStatus, 'prefillTps' | 'decodeTps' | 'cacheHitRate' | 'kvCacheUsage'>> {
const out: Partial<Pick<NodeStatus, 'prefillTps' | 'decodeTps' | 'cacheHitRate' | 'kvCacheUsage'>> = {};
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<NodeStatus> {
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<NodeStatus> {
// 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<string, string>,
timeoutMs: number,
fetchImpl: typeof fetch,
externalSignal?: AbortSignal,
): Promise<BackendMetricsReading> {
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<string, string>,
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<NodeStatus> {
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<NodeStatus> {
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<NodeStatus> {
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<string>();
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<NodeStatus[]> {
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<NodeStatus> => {
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<NodeStatus[]> {
// 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,
};
}