453 lines
20 KiB
TypeScript
453 lines
20 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import {
|
|
buildDirectProbe,
|
|
buildProxyProbe,
|
|
parseLlamaSlots,
|
|
parseLiteLLMHealth,
|
|
parseLlamaMetricsThroughput,
|
|
normalizeWorkerBase,
|
|
} from './backend-probes.js';
|
|
import type { WorkerDef } from '../config.js';
|
|
|
|
function fakeResponse(opts: { status?: number; ok?: boolean; jsonBody?: unknown; textBody?: string; throwOnJson?: boolean }): Response {
|
|
const ok = opts.ok ?? (opts.status === undefined || (opts.status >= 200 && opts.status < 300));
|
|
return {
|
|
ok,
|
|
status: opts.status ?? 200,
|
|
json: async () => {
|
|
if (opts.throwOnJson) throw new Error('json parse failed');
|
|
return opts.jsonBody;
|
|
},
|
|
text: async () => opts.textBody ?? '',
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe('normalizeWorkerBase', () => {
|
|
it('strips trailing slashes', () => {
|
|
expect(normalizeWorkerBase('http://x/')).toBe('http://x');
|
|
expect(normalizeWorkerBase('http://x///')).toBe('http://x');
|
|
});
|
|
it('strips a single trailing /v1', () => {
|
|
expect(normalizeWorkerBase('http://x/v1')).toBe('http://x');
|
|
expect(normalizeWorkerBase('http://x/v1/')).toBe('http://x');
|
|
});
|
|
it('leaves other paths alone', () => {
|
|
expect(normalizeWorkerBase('http://x/api')).toBe('http://x/api');
|
|
});
|
|
});
|
|
|
|
describe('parseLlamaSlots', () => {
|
|
it('counts processing slots and picks the first model', () => {
|
|
const parsed = parseLlamaSlots([
|
|
{ id: 0, is_processing: true, model: 'qwen3:8b' },
|
|
{ id: 1, is_processing: false, model: 'qwen3:8b' },
|
|
{ id: 2, is_processing: true, model: 'qwen3:8b' },
|
|
]);
|
|
expect(parsed).toEqual({ busySlots: 2, totalSlots: 3, loadedModel: 'qwen3:8b' });
|
|
});
|
|
|
|
it('handles legacy state-number shape', () => {
|
|
const parsed = parseLlamaSlots([
|
|
{ id: 0, state: 1, model: 'm' },
|
|
{ id: 1, state: 0, model: 'm' },
|
|
]);
|
|
expect(parsed.busySlots).toBe(1);
|
|
expect(parsed.totalSlots).toBe(2);
|
|
});
|
|
|
|
it('handles envelope { slots: [...] }', () => {
|
|
const parsed = parseLlamaSlots({ slots: [{ id: 0, is_processing: false }] });
|
|
expect(parsed.totalSlots).toBe(1);
|
|
});
|
|
|
|
it('returns zeros on garbage', () => {
|
|
const parsed = parseLlamaSlots(null);
|
|
expect(parsed).toEqual({ busySlots: 0, totalSlots: 0, loadedModel: null });
|
|
});
|
|
});
|
|
|
|
describe('parseLiteLLMHealth', () => {
|
|
it('returns one NodeStatus per healthy + unhealthy deployment', () => {
|
|
const ts = '2026-05-18T00:00:00.000Z';
|
|
const out = parseLiteLLMHealth({
|
|
healthy_endpoints: [{ model: 'gpu-a' }, { litellm_params: { model: 'gpu-b' } }],
|
|
unhealthy_endpoints: [{ model: 'gpu-down', error: 'timeout' }],
|
|
}, 'pool', ts);
|
|
// Order is unhealthy-first per Phase C dedup policy (see
|
|
// parseLiteLLMHealth), but consumers shouldn't rely on it — assert
|
|
// by set membership instead.
|
|
expect(out.map(s => s.nodeId).sort()).toEqual(['gpu-a', 'gpu-b', 'gpu-down']);
|
|
expect(out.find(s => s.nodeId === 'gpu-down')!.online).toBe(false);
|
|
expect(out.find(s => s.nodeId === 'gpu-down')!.lastProbeError).toBe('timeout');
|
|
expect(out.every(s => s.workerId === 'pool')).toBe(true);
|
|
expect(out.every(s => s.source === 'proxy')).toBe(true);
|
|
});
|
|
|
|
it('dedupes by deployment id with unhealthy winning precedence (flap detection)', () => {
|
|
const ts = 't';
|
|
const out = parseLiteLLMHealth({
|
|
healthy_endpoints: [{ model: 'gpu-a' }],
|
|
unhealthy_endpoints: [{ model: 'gpu-a', error: 'flap' }],
|
|
}, 'pool', ts);
|
|
// Phase C: when the same deployment appears in both lists, the
|
|
// unhealthy entry must surface so operators don't see a misleading
|
|
// green icon for a flapping backend.
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.online).toBe(false);
|
|
expect(out[0]!.lastProbeError).toBe('flap');
|
|
});
|
|
|
|
it('preserves unhealthy-only entries (regression)', () => {
|
|
const ts = 't';
|
|
const out = parseLiteLLMHealth({
|
|
unhealthy_endpoints: [{ model: 'gpu-down', error: 'unreachable' }],
|
|
}, 'pool', ts);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.online).toBe(false);
|
|
expect(out[0]!.lastProbeError).toBe('unreachable');
|
|
});
|
|
|
|
it('skips entries with no deployment id', () => {
|
|
const out = parseLiteLLMHealth({ healthy_endpoints: [{ /* nothing */ }] }, 'pool', 't');
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
it('extracts .message from LiteLLM post-1.40 object-form error', () => {
|
|
// LiteLLM ≥ 1.40 wraps errors as { message, type } objects. The
|
|
// previous string-only guard silently dropped the object form and
|
|
// left lastProbeError undefined, producing "red icon, no reason".
|
|
const out = parseLiteLLMHealth({
|
|
unhealthy_endpoints: [{
|
|
model: 'gpu-down',
|
|
error: { message: 'Timeout', type: 'Timeout' },
|
|
}],
|
|
}, 'pool', 't');
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.online).toBe(false);
|
|
expect(out[0]!.lastProbeError).toBe('Timeout');
|
|
});
|
|
|
|
it('falls back to JSON.stringify for arbitrary object errors (no .message)', () => {
|
|
// Some LiteLLM forks/middleware return structured errors with
|
|
// neither `message` nor a string form. Surfacing the JSON keeps
|
|
// operators able to diagnose without code spelunking.
|
|
const out = parseLiteLLMHealth({
|
|
unhealthy_endpoints: [{
|
|
model: 'gpu-x',
|
|
error: { code: 503, retryAfter: 30 },
|
|
}],
|
|
}, 'pool', 't');
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.lastProbeError).toBe('{"code":503,"retryAfter":30}');
|
|
});
|
|
|
|
it('leaves lastProbeError undefined when error is null/undefined (regression)', () => {
|
|
const out = parseLiteLLMHealth({
|
|
unhealthy_endpoints: [{ model: 'gpu-z', error: null }],
|
|
}, 'pool', 't');
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.lastProbeError).toBeUndefined();
|
|
});
|
|
|
|
it('still preserves string-form errors for pre-1.40 LiteLLM (regression)', () => {
|
|
const out = parseLiteLLMHealth({
|
|
unhealthy_endpoints: [{ model: 'gpu-old', error: 'classic string error' }],
|
|
}, 'pool', 't');
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.lastProbeError).toBe('classic string error');
|
|
});
|
|
|
|
describe('AAO Gateway extension (aao_busy_slots / aao_total_slots)', () => {
|
|
it('inherits the gateway-aggregated busy view when aao_* fields are present', () => {
|
|
// Multi-client sharing: gateway aggregates /slots across every
|
|
// AAO that talks to it, then ships the totals on /health. Each
|
|
// client AAO's local registry inherits the union view here so
|
|
// the dashboard tree reflects "some other AAO is using GPU X
|
|
// right now" even when this AAO isn't.
|
|
const out = parseLiteLLMHealth({
|
|
healthy_endpoints: [
|
|
{ model: 'gpu-a', litellm_params: { model: 'gpu-a' }, aao_busy_slots: 3, aao_total_slots: 4 },
|
|
{ model: 'gpu-b', litellm_params: { model: 'gpu-b' }, aao_busy_slots: 0, aao_total_slots: 4 },
|
|
],
|
|
}, 'gw', 't');
|
|
expect(out).toHaveLength(2);
|
|
const a = out.find((x) => x.nodeId === 'gpu-a')!;
|
|
const b = out.find((x) => x.nodeId === 'gpu-b')!;
|
|
expect(a).toMatchObject({ busy: true, busySlots: 3, totalSlots: 4, online: true });
|
|
expect(b).toMatchObject({ busy: false, busySlots: 0, totalSlots: 4, online: true });
|
|
});
|
|
|
|
it('treats missing aao_* fields as zero (vanilla LiteLLM compat)', () => {
|
|
const out = parseLiteLLMHealth({
|
|
healthy_endpoints: [{ model: 'm1', litellm_params: { model: 'm1' } }],
|
|
}, 'gw', 't');
|
|
expect(out[0]).toMatchObject({ busy: false, busySlots: 0, totalSlots: 0 });
|
|
});
|
|
|
|
it('coerces malformed aao_busy_slots to 0 without throwing', () => {
|
|
const out = parseLiteLLMHealth({
|
|
healthy_endpoints: [
|
|
{ model: 'm1', litellm_params: { model: 'm1' }, aao_busy_slots: 'three', aao_total_slots: -2 },
|
|
],
|
|
}, 'gw', 't');
|
|
expect(out[0]).toMatchObject({ busy: false, busySlots: 0, totalSlots: 0 });
|
|
});
|
|
|
|
it('floors fractional aao_busy_slots', () => {
|
|
const out = parseLiteLLMHealth({
|
|
healthy_endpoints: [
|
|
{ model: 'm1', litellm_params: { model: 'm1' }, aao_busy_slots: 2.9, aao_total_slots: 4 },
|
|
],
|
|
}, 'gw', 't');
|
|
expect(out[0]!.busySlots).toBe(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseLlamaMetricsThroughput', () => {
|
|
it('reads llamacpp:tokens_per_second when present', () => {
|
|
const body = [
|
|
'# HELP llamacpp:tokens_per_second current generation throughput',
|
|
'# TYPE llamacpp:tokens_per_second gauge',
|
|
'llamacpp:tokens_per_second 42.5',
|
|
].join('\n');
|
|
expect(parseLlamaMetricsThroughput(body)).toBeCloseTo(42.5);
|
|
});
|
|
|
|
it('falls back to prompt_tokens_seconds when tokens_per_second is absent', () => {
|
|
const body = 'llamacpp:prompt_tokens_seconds 123.4\n';
|
|
expect(parseLlamaMetricsThroughput(body)).toBeCloseTo(123.4);
|
|
});
|
|
|
|
it('tolerates label sets in the metric line', () => {
|
|
const body = 'llamacpp:tokens_per_second{model="qwen3:8b"} 99\n';
|
|
expect(parseLlamaMetricsThroughput(body)).toBe(99);
|
|
});
|
|
|
|
it('returns null when no recognised gauge appears', () => {
|
|
expect(parseLlamaMetricsThroughput('# nothing useful here\nfoo 1\n')).toBeNull();
|
|
expect(parseLlamaMetricsThroughput('')).toBeNull();
|
|
});
|
|
|
|
it('rejects negative or non-finite values', () => {
|
|
expect(parseLlamaMetricsThroughput('llamacpp:tokens_per_second -1\n')).toBeNull();
|
|
expect(parseLlamaMetricsThroughput('llamacpp:tokens_per_second NaN\n')).toBeNull();
|
|
});
|
|
|
|
it('does not match the metric name appearing inside another line', () => {
|
|
// The anchored regex requires the metric name at line start.
|
|
const body = '# llamacpp:tokens_per_second 9999 (in a comment)\n';
|
|
expect(parseLlamaMetricsThroughput(body)).toBeNull();
|
|
});
|
|
|
|
it('returns max across multi-label series within the same metric family', () => {
|
|
// Multi-model llama-server (1 process serving multiple slots with
|
|
// distinct `{model="..."}` labels) emits one line per label set.
|
|
// We must take the max — first-match would silently drop the
|
|
// faster sibling series.
|
|
const body = [
|
|
'llamacpp:tokens_per_second{model="qwen3:8b"} 5.0',
|
|
'llamacpp:tokens_per_second{model="qwen3:32b"} 80.0',
|
|
'llamacpp:tokens_per_second{model="qwen3:14b"} 42.0',
|
|
].join('\n') + '\n';
|
|
expect(parseLlamaMetricsThroughput(body)).toBeCloseTo(80.0);
|
|
});
|
|
|
|
it('still returns the single value for single-label series (regression)', () => {
|
|
const body = 'llamacpp:tokens_per_second{model="qwen3:8b"} 42.5\n';
|
|
expect(parseLlamaMetricsThroughput(body)).toBeCloseTo(42.5);
|
|
});
|
|
|
|
it('ignores NaN/negative values in multi-label series and returns the max of the valid ones', () => {
|
|
const body = [
|
|
'llamacpp:tokens_per_second{model="a"} NaN',
|
|
'llamacpp:tokens_per_second{model="b"} -1',
|
|
'llamacpp:tokens_per_second{model="c"} 7.5',
|
|
'llamacpp:tokens_per_second{model="d"} 3.2',
|
|
].join('\n') + '\n';
|
|
expect(parseLlamaMetricsThroughput(body)).toBeCloseTo(7.5);
|
|
});
|
|
|
|
it('still prefers tokens_per_second over prompt_tokens_seconds (regression)', () => {
|
|
// If both families appear, tokens_per_second wins; we must not
|
|
// mix max-across-families.
|
|
const body = [
|
|
'llamacpp:tokens_per_second{model="a"} 10.0',
|
|
'llamacpp:prompt_tokens_seconds{model="a"} 9999.0',
|
|
].join('\n') + '\n';
|
|
expect(parseLlamaMetricsThroughput(body)).toBeCloseTo(10.0);
|
|
});
|
|
});
|
|
|
|
describe('buildDirectProbe', () => {
|
|
const ts = '2026-05-18T00:00:00.000Z';
|
|
|
|
it('returns busySlots / totalSlots when /slots responds', async () => {
|
|
const fetchImpl = vi.fn()
|
|
// /slots
|
|
.mockResolvedValueOnce(fakeResponse({
|
|
jsonBody: [
|
|
{ id: 0, is_processing: true, model: 'qwen3:8b' },
|
|
{ id: 1, is_processing: false, model: 'qwen3:8b' },
|
|
],
|
|
}))
|
|
// /metrics — best-effort, returns empty so throughput stays null
|
|
.mockResolvedValueOnce(fakeResponse({ textBody: '' })) as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
const status = await probe({ id: 'w1', endpoint: 'http://w1/v1' });
|
|
expect(status.online).toBe(true);
|
|
expect(status.busySlots).toBe(1);
|
|
expect(status.totalSlots).toBe(2);
|
|
expect(status.busy).toBe(true);
|
|
expect(status.loadedModel).toBe('qwen3:8b');
|
|
// Verify the URL was normalized (no /v1) and reached /slots first:
|
|
const call = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]!;
|
|
expect(call[0]).toBe('http://w1/slots');
|
|
// Second call must be /metrics, also at the root.
|
|
const call2 = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[1]!;
|
|
expect(call2[0]).toBe('http://w1/metrics');
|
|
});
|
|
|
|
it('reports throughputTps when /metrics surfaces a gauge', async () => {
|
|
const fetchImpl = vi.fn()
|
|
.mockResolvedValueOnce(fakeResponse({ jsonBody: [{ id: 0, is_processing: true, model: 'qwen' }] }))
|
|
.mockResolvedValueOnce(fakeResponse({ textBody: 'llamacpp:tokens_per_second 87.5\n' })) as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
const status = await probe({ id: 'w1', endpoint: 'http://w1' });
|
|
expect(status.throughputTps).toBeCloseTo(87.5);
|
|
});
|
|
|
|
it('leaves throughputTps null when /metrics 404s (--metrics opt-in)', async () => {
|
|
const fetchImpl = vi.fn()
|
|
.mockResolvedValueOnce(fakeResponse({ jsonBody: [] }))
|
|
.mockResolvedValueOnce(fakeResponse({ status: 404, ok: false })) as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
const status = await probe({ id: 'w1', endpoint: 'http://w1' });
|
|
expect(status.online).toBe(true);
|
|
expect(status.throughputTps).toBeNull();
|
|
});
|
|
|
|
it('does not demote online state when /metrics throws', async () => {
|
|
const fetchImpl = vi.fn()
|
|
.mockResolvedValueOnce(fakeResponse({ jsonBody: [] }))
|
|
.mockRejectedValueOnce(new Error('econnreset')) as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
const status = await probe({ id: 'w1', endpoint: 'http://w1' });
|
|
expect(status.online).toBe(true);
|
|
expect(status.throughputTps).toBeNull();
|
|
});
|
|
|
|
it('does NOT forward worker.apiKey on direct probes (would leak LiteLLM virtual key)', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(fakeResponse({ jsonBody: [] })) as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
await probe({ id: 'w1', endpoint: 'http://w1', apiKey: 'sk-litellm-virtual-tok' });
|
|
const init = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]![1] as RequestInit;
|
|
const headers = init.headers as Record<string, string>;
|
|
expect(headers.Authorization).toBeUndefined();
|
|
// Also assert the apiKey never leaked through any other header name.
|
|
for (const v of Object.values(headers)) {
|
|
expect(v).not.toContain('sk-litellm-virtual-tok');
|
|
}
|
|
});
|
|
|
|
it('also omits Authorization on /health fallback (no apiKey on direct path)', async () => {
|
|
const mock = vi.fn()
|
|
.mockResolvedValueOnce(fakeResponse({ status: 404, ok: false }))
|
|
.mockResolvedValueOnce(fakeResponse({ status: 200, ok: true, jsonBody: { status: 'ok' } }));
|
|
const fetchImpl = mock as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
await probe({ id: 'w1', endpoint: 'http://w1', apiKey: 'sk-virtual' });
|
|
const healthInit = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[1]![1] as RequestInit;
|
|
expect((healthInit.headers as Record<string, string>).Authorization).toBeUndefined();
|
|
});
|
|
|
|
it('falls back to /health on 404 (--no-slots disabled)', async () => {
|
|
const mock = vi.fn()
|
|
.mockResolvedValueOnce(fakeResponse({ status: 404, ok: false }))
|
|
.mockResolvedValueOnce(fakeResponse({ status: 200, ok: true, jsonBody: { status: 'ok' } }));
|
|
const fetchImpl = mock as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
const status = await probe({ id: 'w1', endpoint: 'http://w1', model: 'qwen' });
|
|
expect(status.online).toBe(true);
|
|
expect(status.totalSlots).toBe(0);
|
|
expect(status.loadedModel).toBe('qwen');
|
|
});
|
|
|
|
it('reports offline + error message when fetch rejects', async () => {
|
|
const fetchImpl = vi.fn().mockRejectedValue(new Error('econnrefused')) as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
const status = await probe({ id: 'w1', endpoint: 'http://w1' });
|
|
expect(status.online).toBe(false);
|
|
expect(status.lastProbeError).toBe('econnrefused');
|
|
});
|
|
|
|
it('reports offline on non-fallback non-OK HTTP status', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(fakeResponse({ status: 500, ok: false })) as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, now: () => ts });
|
|
const status = await probe({ id: 'w1', endpoint: 'http://w1' });
|
|
expect(status.online).toBe(false);
|
|
expect(status.lastProbeError).toContain('500');
|
|
});
|
|
|
|
it('aborts after timeoutMs', async () => {
|
|
const fetchImpl = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
|
return new Promise<Response>((_, reject) => {
|
|
init.signal!.addEventListener('abort', () => reject(new Error('aborted')));
|
|
});
|
|
}) as unknown as typeof fetch;
|
|
const probe = buildDirectProbe({ fetchImpl, timeoutMs: 10, now: () => ts });
|
|
const status = await probe({ id: 'w1', endpoint: 'http://w1' });
|
|
expect(status.online).toBe(false);
|
|
expect(status.lastProbeError).toBe('aborted');
|
|
});
|
|
});
|
|
|
|
describe('buildProxyProbe', () => {
|
|
const ts = '2026-05-18T00:00:00.000Z';
|
|
|
|
it('returns one status per deployment on success', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(fakeResponse({
|
|
jsonBody: {
|
|
healthy_endpoints: [{ model: 'gpu-a' }, { model: 'gpu-b' }],
|
|
},
|
|
})) as unknown as typeof fetch;
|
|
const probe = buildProxyProbe({ fetchImpl, now: () => ts });
|
|
const statuses = await probe({ id: 'pool', endpoint: 'http://litellm/v1', proxy: true });
|
|
expect(statuses.map(s => s.nodeId).sort()).toEqual(['gpu-a', 'gpu-b']);
|
|
expect(statuses.every(s => s.online)).toBe(true);
|
|
// URL was normalised: /v1 stripped, /health appended at the root.
|
|
const call = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]!;
|
|
expect(call[0]).toBe('http://litellm/health');
|
|
});
|
|
|
|
it('forwards Bearer Authorization on proxy probes when apiKey is set', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(fakeResponse({
|
|
jsonBody: { healthy_endpoints: [], unhealthy_endpoints: [] },
|
|
})) as unknown as typeof fetch;
|
|
const probe = buildProxyProbe({ fetchImpl, now: () => ts });
|
|
await probe({ id: 'pool', endpoint: 'http://litellm/v1', proxy: true, apiKey: 'sk-virtual' });
|
|
const init = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]![1] as RequestInit;
|
|
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-virtual');
|
|
});
|
|
|
|
it('returns a single offline status when /health is unreachable', async () => {
|
|
const fetchImpl = vi.fn().mockRejectedValue(new Error('econnrefused')) as unknown as typeof fetch;
|
|
const probe = buildProxyProbe({ fetchImpl, now: () => ts });
|
|
const statuses = await probe({ id: 'pool', endpoint: 'http://litellm/v1', proxy: true });
|
|
expect(statuses).toHaveLength(1);
|
|
expect(statuses[0]!.online).toBe(false);
|
|
expect(statuses[0]!.nodeId).toBe('pool');
|
|
});
|
|
|
|
it('returns a single status when the proxy is alive but reports zero deployments', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(fakeResponse({
|
|
jsonBody: { healthy_endpoints: [], unhealthy_endpoints: [] },
|
|
})) as unknown as typeof fetch;
|
|
const probe = buildProxyProbe({ fetchImpl, now: () => ts });
|
|
const statuses = await probe({ id: 'pool', endpoint: 'http://litellm', proxy: true });
|
|
expect(statuses).toHaveLength(1);
|
|
expect(statuses[0]!.nodeId).toBe('pool');
|
|
expect(statuses[0]!.online).toBe(true);
|
|
});
|
|
});
|