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.
- package/README.md +28 -1
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +166 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +42 -0
- package/dist/config.js +121 -7
- package/dist/config.js.map +1 -1
- package/dist/dispatch.d.ts +76 -0
- package/dist/dispatch.js +150 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/dotenv.d.ts +12 -0
- package/dist/dotenv.js +64 -0
- package/dist/dotenv.js.map +1 -0
- package/dist/key-checker.d.ts +9 -2
- package/dist/key-checker.js +166 -20
- package/dist/key-checker.js.map +1 -1
- package/dist/pool-health.d.ts +36 -0
- package/dist/pool-health.js +108 -0
- package/dist/pool-health.js.map +1 -0
- package/dist/server.js +38 -0
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/skills/llm-relay/SKILL.md +53 -14
package/dist/key-checker.js
CHANGED
|
@@ -1,20 +1,132 @@
|
|
|
1
1
|
import { fetchProviderQuota } from "./ping/quota.js";
|
|
2
2
|
import { candidateEnvNames } from "./authEnv.js";
|
|
3
|
-
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
245
|
+
};
|
|
246
|
+
return Promise.all(entries.map(checkOne));
|
|
101
247
|
}
|
|
102
248
|
//# sourceMappingURL=key-checker.js.map
|
package/dist/key-checker.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"key-checker.js","sourceRoot":"","sources":["../src/key-checker.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;
|
|
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":"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"}
|
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" });
|