llm-relay 0.8.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,132 @@
1
1
  import { fetchProviderQuota } from "./ping/quota.js";
2
2
  import { candidateEnvNames } from "./authEnv.js";
3
- /** Pre-flight check all configured provider API keys. */
3
+ import { splitSpec } from "./config.js";
4
+ /**
5
+ * Per-request cap for key checking. Independent of a provider's own `timeoutMs`, which is
6
+ * sized for real completions (minutes) — a status command must stay answerable even when a
7
+ * configured host is black-holing connections.
8
+ */
9
+ const KEY_CHECK_TIMEOUT_MS = 45_000;
10
+ function withTimeout() {
11
+ return typeof AbortSignal?.timeout === "function" ? { signal: AbortSignal.timeout(KEY_CHECK_TIMEOUT_MS) } : {};
12
+ }
13
+ /**
14
+ * Does this endpoint actually require credentials? Probed by calling it with none.
15
+ * A 401/403 means the earlier authenticated 200 was real evidence about the key.
16
+ * Anything else means the endpoint is public and tells us nothing.
17
+ * On a network error we assume it IS gated — never downgrade a working key on a blip.
18
+ */
19
+ async function isAuthGated(url, fetchFn) {
20
+ try {
21
+ const bare = await fetchFn(url, { method: "GET", headers: { "Content-Type": "application/json" }, ...withTimeout() });
22
+ return bare.status === 401 || bare.status === 403;
23
+ }
24
+ catch {
25
+ return true;
26
+ }
27
+ }
28
+ /**
29
+ * Ask an endpoint that definitely needs the key: a 1-token completion.
30
+ *
31
+ * `modelId` should be a REAL id from the provider's own catalogue. A throwaway id like
32
+ * "probe" looks tidy but is unreliable: some providers reject the unknown model with a 401
33
+ * (reported as a bad key when the key was fine) and others stall routing it until the
34
+ * request times out. Both turn a healthy provider into a false alarm — the same class of
35
+ * bug as the false VALID this check exists to fix, just pointing the other way.
36
+ */
37
+ async function probeAuthenticated(p, apiKey, fetchFn, modelId) {
38
+ const isOpenAi = p.kind === "openai";
39
+ const url = isOpenAi ? `${p.base}/chat/completions` : `${p.base}/v1/messages`;
40
+ const headers = { "Content-Type": "application/json" };
41
+ if (p.authHeader === "x-api-key" || p.kind === "anthropic") {
42
+ headers["x-api-key"] = apiKey;
43
+ headers["anthropic-version"] = "2023-06-01";
44
+ }
45
+ else {
46
+ headers["authorization"] = apiKey.startsWith("Bearer ") ? apiKey : `Bearer ${apiKey}`;
47
+ }
48
+ const body = JSON.stringify({
49
+ model: modelId,
50
+ max_tokens: 1,
51
+ messages: [{ role: "user", content: "hi" }],
52
+ });
53
+ try {
54
+ const r = await fetchFn(url, { method: "POST", headers, body, ...withTimeout() });
55
+ if (r.status === 429) {
56
+ return { status: "rate_limited", httpStatus: 429, message: "Rate limited or quota exhausted (HTTP 429)" };
57
+ }
58
+ if (r.status !== 401 && r.status !== 403) {
59
+ // 2xx, or a 400/404 rejecting the request on its contents — either way it got past auth.
60
+ return { status: "valid", httpStatus: r.status, message: "Key verified (authenticated probe)" };
61
+ }
62
+ // A 401/403 is ambiguous: the key may be bad, OR the key may be fine and simply not
63
+ // entitled to this particular model (premium tiers are common in a free-tier roster, and
64
+ // the catalogue lists them alongside the free ones). Send the SAME request anonymously:
65
+ // if the answer changes, the credential demonstrably did something and the wall is about
66
+ // the model, not the key. If it does not change, we have learned nothing and must say so
67
+ // rather than accuse a working key.
68
+ const anon = await fetchFn(url, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body,
72
+ ...withTimeout(),
73
+ });
74
+ if (anon.status !== r.status) {
75
+ return {
76
+ status: "valid",
77
+ httpStatus: r.status,
78
+ message: `Key authenticated (HTTP ${r.status} is the probe model not being available on this plan)`,
79
+ };
80
+ }
81
+ return {
82
+ status: "unverified",
83
+ httpStatus: r.status,
84
+ message: `Could not confirm — /models is public and the probe model answers HTTP ${r.status} with or without the key`,
85
+ };
86
+ }
87
+ catch (e) {
88
+ return { status: "unreachable", httpStatus: undefined, message: `Network error: ${e.message}` };
89
+ }
90
+ }
91
+ /** First model id this config routes to each provider, across pools, tiers and default. */
92
+ function routedModelsByProvider(cfg) {
93
+ const out = new Map();
94
+ const raw = [
95
+ ...Object.values(cfg.routing.pools ?? {}),
96
+ ...Object.values(cfg.routing.tiers ?? {}),
97
+ cfg.routing.default,
98
+ ];
99
+ for (const entry of raw) {
100
+ for (const spec of Array.isArray(entry) ? entry : entry ? [entry] : []) {
101
+ const { provider, model } = splitSpec(spec);
102
+ if (model && !out.has(provider))
103
+ out.set(provider, model);
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * Pre-flight check all configured provider API keys.
110
+ *
111
+ * Providers are checked CONCURRENTLY. A rich config can declare a dozen-plus providers, and
112
+ * one unreachable host (a local daemon that isn't running) must not add its whole timeout to
113
+ * everyone else's wait — checked serially that turns a status command into a minutes-long one.
114
+ * Result order still follows config order.
115
+ */
4
116
  export async function validateProviderKeys(cfg, fetchFn = fetch) {
5
- const results = [];
6
- for (const [name, p] of Object.entries(cfg.providers)) {
117
+ const entries = Object.entries(cfg.providers);
118
+ const routed = routedModelsByProvider(cfg);
119
+ const checkOne = async ([name, p]) => {
7
120
  const envVarName = p.authEnv;
8
121
  const apiKey = envVarName ? process.env[envVarName] : undefined;
9
122
  if (envVarName && !apiKey) {
10
- results.push({
123
+ return {
11
124
  provider: name,
12
125
  authEnv: envVarName,
13
126
  hasEnvKey: false,
14
127
  status: "missing_env",
15
128
  message: `No key found — set ${candidateEnvNames(name, envVarName).slice(0, 4).join(" or ")}`,
16
- });
17
- continue;
129
+ };
18
130
  }
19
131
  try {
20
132
  // 1. Fetch quota if applicable
@@ -30,19 +142,19 @@ export async function validateProviderKeys(cfg, fetchFn = fetch) {
30
142
  headers["authorization"] = apiKey.startsWith("Bearer ") ? apiKey : `Bearer ${apiKey}`;
31
143
  }
32
144
  }
33
- const resp = await fetchFn(url, { method: "GET", headers });
145
+ const resp = await fetchFn(url, { method: "GET", headers, ...withTimeout() });
34
146
  if (resp.status === 401 || resp.status === 403) {
35
- results.push({
147
+ return {
36
148
  provider: name,
37
149
  authEnv: envVarName,
38
150
  hasEnvKey: true,
39
151
  status: "invalid_key",
40
152
  httpStatus: resp.status,
41
153
  message: `Authentication failed (HTTP ${resp.status})`,
42
- });
154
+ };
43
155
  }
44
156
  else if (resp.status === 429) {
45
- results.push({
157
+ return {
46
158
  provider: name,
47
159
  authEnv: envVarName,
48
160
  hasEnvKey: true,
@@ -50,22 +162,56 @@ export async function validateProviderKeys(cfg, fetchFn = fetch) {
50
162
  httpStatus: 429,
51
163
  message: "Rate limited or quota exhausted (HTTP 429)",
52
164
  quotaPercent: quota.quotaPercent,
53
- });
165
+ };
54
166
  }
55
167
  else if (resp.ok || resp.status === 400 || resp.status === 404 || resp.status === 405) {
56
168
  // 200 OK or endpoint-level expected response (e.g. GET on /messages returning 405 Method Not Allowed)
57
169
  let modelsCount;
170
+ let firstModelId;
58
171
  if (resp.ok && url.endsWith("/models")) {
59
172
  try {
60
173
  const body = (await resp.json());
61
- if (Array.isArray(body.data))
174
+ if (Array.isArray(body.data)) {
62
175
  modelsCount = body.data.length;
176
+ const first = body.data.find((m) => typeof m?.id === "string");
177
+ if (first)
178
+ firstModelId = first.id;
179
+ }
63
180
  }
64
181
  catch {
65
182
  /* ignore JSON parse */
66
183
  }
67
184
  }
68
- results.push({
185
+ // A 200 from /models only proves the KEY works if that endpoint is actually
186
+ // auth-gated. Several providers (OpenRouter among them) serve it publicly, so a
187
+ // revoked key still returns the full catalogue and this check reported VALID while
188
+ // every real request 401'd. Re-probe WITHOUT the key: if it still succeeds, the
189
+ // endpoint proved nothing and we must ask an authenticated endpoint instead.
190
+ // Needs a real model id to probe with; without one, escalation is less reliable
191
+ // than the listing we already have, so keep the listing verdict.
192
+ // Prefer a model this config actually ROUTES to the provider over the catalogue's
193
+ // first entry. Free-tier rosters list premium models the key legitimately cannot
194
+ // touch, and probing one of those produces a 401/403 that says nothing about the
195
+ // key. The routed model is both the one the user cares about and the one most
196
+ // likely to be reachable on their plan.
197
+ const probeModel = routed.get(name) ?? firstModelId;
198
+ if (resp.ok && apiKey && url.endsWith("/models") && probeModel) {
199
+ const gated = await isAuthGated(url, fetchFn);
200
+ if (!gated) {
201
+ const verdict = await probeAuthenticated(p, apiKey, fetchFn, probeModel);
202
+ return {
203
+ provider: name,
204
+ authEnv: envVarName,
205
+ hasEnvKey: true,
206
+ status: verdict.status,
207
+ httpStatus: verdict.httpStatus,
208
+ message: verdict.message,
209
+ quotaPercent: quota.quotaPercent,
210
+ modelsFound: modelsCount,
211
+ };
212
+ }
213
+ }
214
+ return {
69
215
  provider: name,
70
216
  authEnv: envVarName,
71
217
  hasEnvKey: true,
@@ -74,29 +220,29 @@ export async function validateProviderKeys(cfg, fetchFn = fetch) {
74
220
  message: "Key verified & healthy",
75
221
  quotaPercent: quota.quotaPercent,
76
222
  modelsFound: modelsCount,
77
- });
223
+ };
78
224
  }
79
225
  else {
80
- results.push({
226
+ return {
81
227
  provider: name,
82
228
  authEnv: envVarName,
83
229
  hasEnvKey: true,
84
230
  status: "unreachable",
85
231
  httpStatus: resp.status,
86
232
  message: `Provider returned HTTP ${resp.status}`,
87
- });
233
+ };
88
234
  }
89
235
  }
90
236
  catch (e) {
91
- results.push({
237
+ return {
92
238
  provider: name,
93
239
  authEnv: envVarName,
94
240
  hasEnvKey: true,
95
241
  status: "unreachable",
96
242
  message: `Network error: ${e.message}`,
97
- });
243
+ };
98
244
  }
99
- }
100
- return results;
245
+ };
246
+ return Promise.all(entries.map(checkOne));
101
247
  }
102
248
  //# sourceMappingURL=key-checker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"key-checker.js","sourceRoot":"","sources":["../src/key-checker.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"key-checker.js","sourceRoot":"","sources":["../src/key-checker.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pool liveness probing — `llm-relay pools [--probe]`.
3
+ *
4
+ * Nothing used to detect a dead pool member. A model can be listed in a provider's catalogue
5
+ * and still refuse every request (de-listed behind the scenes, gated to a paid tier, or
6
+ * routed to a function that no longer exists), so config-time validation cannot see it. The
7
+ * observed failure mode: a pool whose top-ranked member 404'd on every call while the pool
8
+ * looked healthy, leaving offload running on a single live model with failover that existed
9
+ * only on paper.
10
+ *
11
+ * The only trustworthy signal is a real completion, so that is what `--probe` sends.
12
+ */
13
+ import type { Config } from "./config.js";
14
+ export type MemberVerdict = "live" | "empty" | "auth" | "rate_limited" | "missing" | "error";
15
+ export interface MemberHealth {
16
+ pool: string;
17
+ spec: string;
18
+ verdict: MemberVerdict;
19
+ httpStatus?: number | undefined;
20
+ latencyMs?: number | undefined;
21
+ detail?: string | undefined;
22
+ }
23
+ /** Verdicts that mean "this member will never answer until someone changes something". */
24
+ export declare const DEAD_VERDICTS: ReadonlySet<MemberVerdict>;
25
+ /**
26
+ * Send one real completion to a single pool member.
27
+ *
28
+ * `maxTokens` defaults to 400 rather than a token or two because reasoning models spend
29
+ * their budget thinking: at a low cap they return a 200 with EMPTY content, which reads as
30
+ * a broken model when it is only a truncated one.
31
+ */
32
+ export declare function probeMember(pool: string, spec: string, cfg: Config, fetchFn?: typeof fetch, maxTokens?: number, now?: () => number): Promise<MemberHealth>;
33
+ /** Pull assistant text out of either an OpenAI or an Anthropic shaped response. */
34
+ export declare function extractContent(raw: string): string;
35
+ /** Probe every member of every pool. Concurrency-limited so a wide config can't fan out unbounded. */
36
+ export declare function probeAllPools(cfg: Config, fetchFn?: typeof fetch, concurrency?: number): Promise<MemberHealth[]>;
@@ -0,0 +1,108 @@
1
+ import { splitSpec } from "./config.js";
2
+ /** Verdicts that mean "this member will never answer until someone changes something". */
3
+ export const DEAD_VERDICTS = new Set(["missing", "auth"]);
4
+ function authHeaders(p, apiKey) {
5
+ const headers = { "Content-Type": "application/json" };
6
+ if (!apiKey)
7
+ return headers;
8
+ if (p.authHeader === "x-api-key" || p.kind === "anthropic") {
9
+ headers["x-api-key"] = apiKey;
10
+ headers["anthropic-version"] = "2023-06-01";
11
+ }
12
+ else {
13
+ headers["authorization"] = apiKey.startsWith("Bearer ") ? apiKey : `Bearer ${apiKey}`;
14
+ }
15
+ return headers;
16
+ }
17
+ /**
18
+ * Send one real completion to a single pool member.
19
+ *
20
+ * `maxTokens` defaults to 400 rather than a token or two because reasoning models spend
21
+ * their budget thinking: at a low cap they return a 200 with EMPTY content, which reads as
22
+ * a broken model when it is only a truncated one.
23
+ */
24
+ export async function probeMember(pool, spec, cfg, fetchFn = fetch, maxTokens = 400, now = Date.now) {
25
+ const { provider, model } = splitSpec(spec);
26
+ const p = cfg.providers[provider];
27
+ if (!p) {
28
+ return { pool, spec, verdict: "missing", detail: `no provider "${provider}" configured` };
29
+ }
30
+ const apiKey = p.authEnv ? process.env[p.authEnv] : undefined;
31
+ if (p.authEnv && !apiKey) {
32
+ return { pool, spec, verdict: "auth", detail: `${p.authEnv} is not set` };
33
+ }
34
+ const isOpenAi = p.kind === "openai";
35
+ const url = isOpenAi ? `${p.base}/chat/completions` : `${p.base}/v1/messages`;
36
+ const body = JSON.stringify({
37
+ model,
38
+ max_tokens: maxTokens,
39
+ messages: [{ role: "user", content: "Reply with exactly: OK" }],
40
+ });
41
+ const started = now();
42
+ try {
43
+ const r = await fetchFn(url, { method: "POST", headers: authHeaders(p, apiKey), body });
44
+ const latencyMs = now() - started;
45
+ if (r.status === 401 || r.status === 403) {
46
+ return { pool, spec, verdict: "auth", httpStatus: r.status, latencyMs, detail: `HTTP ${r.status}` };
47
+ }
48
+ if (r.status === 429) {
49
+ return { pool, spec, verdict: "rate_limited", httpStatus: 429, latencyMs, detail: "HTTP 429" };
50
+ }
51
+ if (r.status === 404 || r.status === 400) {
52
+ return { pool, spec, verdict: "missing", httpStatus: r.status, latencyMs, detail: `HTTP ${r.status} — model not servable` };
53
+ }
54
+ if (!r.ok) {
55
+ return { pool, spec, verdict: "error", httpStatus: r.status, latencyMs, detail: `HTTP ${r.status}` };
56
+ }
57
+ const text = await r.text();
58
+ const content = extractContent(text);
59
+ if (content.trim().length === 0) {
60
+ return { pool, spec, verdict: "empty", httpStatus: r.status, latencyMs, detail: "200 but no content" };
61
+ }
62
+ return { pool, spec, verdict: "live", httpStatus: r.status, latencyMs };
63
+ }
64
+ catch (e) {
65
+ return { pool, spec, verdict: "error", latencyMs: now() - started, detail: e.message };
66
+ }
67
+ }
68
+ /** Pull assistant text out of either an OpenAI or an Anthropic shaped response. */
69
+ export function extractContent(raw) {
70
+ try {
71
+ const j = JSON.parse(raw);
72
+ const openai = j.choices?.[0]?.message?.content;
73
+ if (typeof openai === "string")
74
+ return openai;
75
+ if (Array.isArray(j.content)) {
76
+ return j.content
77
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
78
+ .map((b) => b.text)
79
+ .join("");
80
+ }
81
+ }
82
+ catch {
83
+ /* fall through */
84
+ }
85
+ return "";
86
+ }
87
+ /** Probe every member of every pool. Concurrency-limited so a wide config can't fan out unbounded. */
88
+ export async function probeAllPools(cfg, fetchFn = fetch, concurrency = 4) {
89
+ const jobs = [];
90
+ for (const [pool, members] of Object.entries(cfg.routing.pools ?? {})) {
91
+ for (const spec of members)
92
+ jobs.push({ pool, spec });
93
+ }
94
+ const out = new Array(jobs.length);
95
+ let next = 0;
96
+ const workers = Array.from({ length: Math.max(1, Math.min(concurrency, jobs.length)) }, async () => {
97
+ for (;;) {
98
+ const i = next++;
99
+ if (i >= jobs.length)
100
+ return;
101
+ const job = jobs[i];
102
+ out[i] = await probeMember(job.pool, job.spec, cfg, fetchFn);
103
+ }
104
+ });
105
+ await Promise.all(workers);
106
+ return out;
107
+ }
108
+ //# sourceMappingURL=pool-health.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool-health.js","sourceRoot":"","sources":["../src/pool-health.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAaxC,0FAA0F;AAC1F,MAAM,CAAC,MAAM,aAAa,GAA+B,IAAI,GAAG,CAAgB,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,CAAC;AAErG,SAAS,WAAW,CAAC,CAAiB,EAAE,MAA0B;IAChE,MAAM,OAAO,GAA2B,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC;IAC/E,IAAI,CAAC,MAAM;QAAE,OAAO,OAAO,CAAC;IAC5B,IAAI,CAAC,CAAC,UAAU,KAAK,WAAW,IAAI,CAAC,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;QAC3D,OAAO,CAAC,WAAW,CAAC,GAAG,MAAM,CAAC;QAC9B,OAAO,CAAC,mBAAmB,CAAC,GAAG,YAAY,CAAC;IAC9C,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,eAAe,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,UAAU,MAAM,EAAE,CAAC;IACxF,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAY,EACZ,IAAY,EACZ,GAAW,EACX,UAAwB,KAAK,EAC7B,SAAS,GAAG,GAAG,EACf,MAAoB,IAAI,CAAC,GAAG;IAE5B,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,CAAC,GAAG,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAClC,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,gBAAgB,QAAQ,cAAc,EAAE,CAAC;IAC5F,CAAC;IACD,MAAM,MAAM,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC9D,IAAI,CAAC,CAAC,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;QACzB,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC,OAAO,aAAa,EAAE,CAAC;IAC5E,CAAC;IAED,MAAM,QAAQ,GAAG,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC;IACrC,MAAM,GAAG,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,mBAAmB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,cAAc,CAAC;IAC9E,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC;QAC1B,KAAK;QACL,UAAU,EAAE,SAAS;QACrB,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,wBAAwB,EAAE,CAAC;KAChE,CAAC,CAAC;IAEH,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC;IACtB,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;QACxF,MAAM,SAAS,GAAG,GAAG,EAAE,GAAG,OAAO,CAAC;QAClC,IAAI,CAAC,CAAC,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACzC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC;QACtG,CAAC;QACD,IAAI,CAAC,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACrB,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;QACjG,CAAC;QACD,IAAI,CAAC,CAAC,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACzC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,MAAM,uBAAuB,EAAE,CAAC;QAC9H,CAAC;QACD,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;YACV,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC;QACvG,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,OAAO,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC;QACrC,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAChC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,oBAAoB,EAAE,CAAC;QACzG,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;IAC1E,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,OAAO,EAAE,MAAM,EAAG,CAAW,CAAC,OAAO,EAAE,CAAC;IACpG,CAAC;AACH,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAGvB,CAAC;QACF,MAAM,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC;QAChD,IAAI,OAAO,MAAM,KAAK,QAAQ;YAAE,OAAO,MAAM,CAAC;QAC9C,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;YAC7B,OAAO,CAAC,CAAC,OAAO;iBACb,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,KAAK,MAAM,IAAI,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC;iBAC/D,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAc,CAAC;iBAC5B,IAAI,CAAC,EAAE,CAAC,CAAC;QACd,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,kBAAkB;IACpB,CAAC;IACD,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,sGAAsG;AACtG,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,GAAW,EACX,UAAwB,KAAK,EAC7B,WAAW,GAAG,CAAC;IAEf,MAAM,IAAI,GAAqC,EAAE,CAAC;IAClD,KAAK,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,EAAE,CAAC;QACtE,KAAK,MAAM,IAAI,IAAI,OAAO;YAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,CAAC;IAED,MAAM,GAAG,GAAmB,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACnD,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,KAAK,IAAI,EAAE;QACjG,SAAS,CAAC;YACR,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC;YACjB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM;gBAAE,OAAO;YAC7B,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAE,CAAC;YACrB,GAAG,CAAC,CAAC,CAAC,GAAG,MAAM,WAAW,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,GAAG,EAAE,OAAO,CAAC,CAAC;QAC/D,CAAC;IACH,CAAC,CAAC,CAAC;IACH,MAAM,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC3B,OAAO,GAAG,CAAC;AACb,CAAC"}
package/dist/server.js CHANGED
@@ -12,6 +12,7 @@ import { ModelCatalog } from "./catalog.js";
12
12
  import { buildRegistry } from "./registry.js";
13
13
  import { buildCandidates } from "./candidates.js";
14
14
  import { offloadState, setOffload } from "./offload.js";
15
+ import { buildDispatch, markExhausted, clearExhausted } from "./dispatch.js";
15
16
  import { toolSchemaMap } from "./anthropic.js";
16
17
  import { PingLoop } from "./ping/cadence.js";
17
18
  import { recordModelCall } from "./ping/runtime-telemetry.js";
@@ -152,6 +153,43 @@ async function handle(req, res, cfg, h) {
152
153
  h.logger.write(baseLog(started, path, model, false, false, 200, "skipped"));
153
154
  return;
154
155
  }
156
+ // The dispatch ladder: which lane the host should hand a delegated task to next. GET reads
157
+ // (with ?lane= to override, ?after= to walk past a spent rung, ?task= to get a runnable
158
+ // command); POST reports a rung spent so the next read walks past it. The relay decides the
159
+ // ORDER and never executes a cli rung itself — spawning agents is the host's job.
160
+ if ((req.method === "GET" || req.method === "POST") && pathname === "/dispatch") {
161
+ if (req.method === "POST") {
162
+ const body = (reqJson ?? {});
163
+ const ttlMs = typeof body.ttlMs === "number" ? body.ttlMs : undefined;
164
+ if (typeof body.clear === "string") {
165
+ clearExhausted(cfg, body.clear);
166
+ }
167
+ else if (body.clear === true) {
168
+ clearExhausted(cfg);
169
+ }
170
+ else if (typeof body.exhausted === "string") {
171
+ if (!markExhausted(cfg, body.exhausted, ttlMs)) {
172
+ failClosed(res, 400, `POST /dispatch: no lane "${body.exhausted}" in routing.ladder`);
173
+ h.logger.write(baseLog(started, path, model, false, false, 400, "skipped"));
174
+ return;
175
+ }
176
+ }
177
+ else {
178
+ failClosed(res, 400, `POST /dispatch needs {"exhausted":"<lane>"} or {"clear":"<lane>"|true}`);
179
+ h.logger.write(baseLog(started, path, model, false, false, 400, "skipped"));
180
+ return;
181
+ }
182
+ }
183
+ const view = buildDispatch(cfg, {
184
+ ...(pickQuery(path, "task") ? { task: pickQuery(path, "task") } : {}),
185
+ ...(pickQuery(path, "lane") ? { lane: pickQuery(path, "lane") } : {}),
186
+ ...(pickQuery(path, "after") ? { after: pickQuery(path, "after") } : {}),
187
+ });
188
+ res.writeHead(200, { "content-type": "application/json" });
189
+ res.end(JSON.stringify(view, null, 2));
190
+ h.logger.write(baseLog(started, path, model, false, false, 200, "skipped"));
191
+ return;
192
+ }
155
193
  if (req.method === "GET" && pathname === "/telemetry") {
156
194
  const report = getTelemetryReport(cfg, globalCircuitBreaker);
157
195
  res.writeHead(200, { "content-type": "application/json" });