This commit is contained in:
+543
-33
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user