sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+543 -33
View File
@@ -7,7 +7,12 @@
* 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` to determine `online` only.
* 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
@@ -32,6 +37,22 @@
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
@@ -91,6 +112,18 @@ 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) ───────────────────────────────────────────
@@ -144,6 +177,191 @@ export function parseLlamaMetricsThroughput(text: string): number | null {
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.
@@ -193,8 +411,19 @@ export function buildDirectProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?
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
@@ -204,29 +433,54 @@ export function buildDirectProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?
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.
return errorStatus(worker, 'direct', err, now());
// 接続先の入れ替えは再起動を伴うので、ここで口の記憶を捨てて
// 次の周回で叩き直す。
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());
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.
const throughputTps = await tryFetchThroughput(
base, headers, timeoutMs, fetchImpl, ctx?.signal,
// 基準は「ノード+実際に叩いた 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,
@@ -236,17 +490,26 @@ export function buildDirectProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?
busy: parsed.busySlots > 0,
busySlots: parsed.busySlots,
totalSlots: parsed.totalSlots,
// /slots を実際に読んだ値。直結の probe は申告値を代入しない。
totalSlotsProbed: true,
loadedModel: parsed.loadedModel ?? worker.model ?? null,
throughputTps,
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.
// 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, ctx?.signal);
return probeHealthFallback(
base, headers, worker, timeoutMs, fetchImpl, now, probeStartedAt,
rateTracker, nowMs, ctx?.signal,
);
}
return errorStatus(
@@ -254,38 +517,47 @@ export function buildDirectProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?
'direct',
new Error(`/slots returned HTTP ${slotsRes.status}`),
now(),
probeStartedAt,
);
};
}
/**
* Best-effort throughput fetch. Returns null on any failure (404 from
* builds without --metrics, network error, parse failure) so the
* primary /slots-derived status is unaffected.
* 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 tryFetchThroughput(
base: string,
async function tryFetchBackendMetrics(
url: string | null,
targetKey: string,
tracker: VllmRateTracker,
nowMs: () => number,
headers: Record<string, string>,
timeoutMs: number,
fetchImpl: typeof fetch,
externalSignal?: AbortSignal,
): Promise<number | null> {
): Promise<BackendMetricsReading> {
if (!url) return EMPTY_METRICS_READING;
try {
const res = await fetchWithTimeout(
`${base}/metrics`,
url,
{ method: 'GET', headers: { ...headers, Accept: 'text/plain' } },
timeoutMs,
fetchImpl,
externalSignal,
);
if (!res.ok) return null;
if (!res.ok) return EMPTY_METRICS_READING;
const text = await res.text();
return parseLlamaMetricsThroughput(text);
return readBackendMetrics(text, targetKey, tracker, nowMs());
} catch {
return null;
return EMPTY_METRICS_READING;
}
}
@@ -296,13 +568,139 @@ async function probeHealthFallback(
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());
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,
@@ -310,14 +708,17 @@ async function probeHealthFallback(
online: true,
busy: false,
busySlots: 0,
// 外部のサービスはレーンという考え方を出さない。0 は「不明」であって
// 「0 レーン」ではないので、検査済みとは名乗らない。
totalSlots: 0,
totalSlotsProbed: false,
loadedModel: worker.model ?? null,
throughputTps: null,
lastSeen: now(),
probeStartedAt,
probedEndpoint: worker.endpoint,
};
} catch (err) {
return errorStatus(worker, 'direct', err, now());
}
};
}
// ── LiteLLM (proxy worker) ─────────────────────────────────────────────────
@@ -342,6 +743,7 @@ interface LiteLLMHealthEntry {
*/
aao_busy_slots?: unknown;
aao_total_slots?: unknown;
aao_total_slots_probed?: unknown;
aao_saturated?: unknown;
aao_last_seen?: unknown;
}
@@ -388,19 +790,41 @@ function extractDeploymentId(entry: LiteLLMHealthEntry): string | null {
* - null/undefined → undefined (caller skips the field)
*/
function extractErrorMessage(err: unknown): string | undefined {
if (typeof err === 'string') return err;
// 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 obj.message;
if (typeof obj.message === 'string') return clean(obj.message);
try {
return JSON.stringify(err);
return clean(JSON.stringify(err));
} catch {
return String(err);
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.
@@ -432,6 +856,11 @@ export function parseLiteLLMHealth(
// 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,
@@ -440,6 +869,7 @@ export function parseLiteLLMHealth(
busy: busySlots > 0,
busySlots,
totalSlots,
totalSlotsProbed,
loadedModel: id,
throughputTps: null,
lastSeen: ts,
@@ -465,8 +895,46 @@ export function buildProxyProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?:
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`;
@@ -474,16 +942,26 @@ export function buildProxyProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?:
try {
res = await fetchWithTimeout(url, { method: 'GET', headers }, timeoutMs, fetchImpl, ctx?.signal);
} catch (err) {
return [errorStatus(worker, 'proxy', err, now())];
return [errorStatus(worker, 'proxy', err, now(), probeStartedAt)];
}
if (!res.ok) {
return [errorStatus(worker, 'proxy', new Error(`/health returned HTTP ${res.status}`), now())];
return [errorStatus(
worker, 'proxy', new Error(`/health returned HTTP ${res.status}`), now(), probeStartedAt,
)];
}
let body: unknown;
// 2xx が返っている=上流は生きている。**本文が LiteLLM の形で読めない
// だけでオフラインにはしない**(issue #1128)。vLLM の /health は 200 を
// 返すが本文が 0 バイトで、空文字を JSON として読むと必ず例外になる。
// 以前はその例外がそのままオフラインの理由になっていたので、ゲートウェイ
// 扱いで vLLM を指すと生きていても必ずオフラインだった。
//
// 読めない本文は「配下を 1 件も報告しなかった」と同じ扱いにして、下の
// 単独行(生きているが配下不明)へ寄せる。
let body: unknown = null;
try {
body = await res.json();
} catch (err) {
return [errorStatus(worker, 'proxy', err, now())];
} catch {
body = null;
}
const ts = now();
const parsed = parseLiteLLMHealth(body, worker.id, ts);
@@ -502,9 +980,20 @@ export function buildProxyProbe(deps: ProbeDeps = {}): (worker: WorkerDef, ctx?:
loadedModel: null,
throughputTps: null,
lastSeen: ts,
probeStartedAt,
probedEndpoint: worker.endpoint,
// 「配下が 0 件」と「配下を読めなかった」は別物。読めなかったほうは
// 画面にそう出す(issue #1128)。読めた結果 0 件なら旗は立てない。
...(isLiteLLMShaped(body) ? {} : { backendsUnknown: true }),
}];
}
return parsed;
// 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)));
};
}
@@ -513,6 +1002,25 @@ function errorStatus(
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,
@@ -525,6 +1033,8 @@ function errorStatus(
loadedModel: null,
throughputTps: null,
lastSeen: ts,
lastProbeError: err instanceof Error ? err.message : String(err),
probeStartedAt,
probedEndpoint: worker.endpoint,
lastProbeError: reason,
};
}