1041 lines
43 KiB
TypeScript
1041 lines
43 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|