@staix/agent-hub 0.12.19 → 0.12.21
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/CHANGELOG.md +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A hub guard proved this request was refused before any task or board effect (screen telemetry is allowed). Never wrap arbitrary failures in this type. */
|
|
2
|
+
export class PreEffectToolRefusal extends Error {
|
|
3
|
+
constructor(message: string) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "PreEffectToolRefusal";
|
|
6
|
+
}
|
|
7
|
+
}
|
package/src/hub/ui.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { taskProgress } from "../ui/task-progress.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
|
|
@@ -20,7 +21,9 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
20
21
|
const now = options.now ?? Date.now;
|
|
21
22
|
const tickets = new Map<string, number>();
|
|
22
23
|
const sessions = new Map<string, number>();
|
|
23
|
-
|
|
24
|
+
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
25
|
+
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
26
|
+
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`);
|
|
24
27
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
25
28
|
.map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
|
|
26
29
|
const headers = {
|
package/src/local/deny.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { basename, sep } from "node:path";
|
|
|
4
4
|
* The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
|
|
5
5
|
* matching calls out of memory. Two lists would drift, and the gap is where a secret leaks.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
// `.agenthub/archive` holds the state directories `ahub reset --all` moved there: hub.db with task text, PII included.
|
|
8
|
+
export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/archive"];
|
|
8
9
|
/** Regex sources, matched against a file's basename. */
|
|
9
10
|
export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
|
|
10
11
|
|
package/src/local/tools.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import { realPath } from "../hub/project.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { isHubWritePath } from "../hub/permission-mode.ts";
|
|
5
|
+
import { isDenied } from "./deny.ts";
|
|
5
6
|
import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
|
|
6
7
|
|
|
7
8
|
export { isDenied };
|
|
@@ -17,14 +18,11 @@ export interface ToolContext {
|
|
|
17
18
|
/** Turn cancellation signal, including the execution-budget wall cap. */
|
|
18
19
|
signal?: AbortSignal;
|
|
19
20
|
/** Ask the console. Resolves false on deny or timeout. */
|
|
20
|
-
permit: (title: string) => Promise<boolean>;
|
|
21
|
+
permit: (title: string, tool?: string, signal?: AbortSignal, path?: string) => Promise<boolean>;
|
|
21
22
|
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
22
23
|
send: (text: string, to?: string[]) => string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/** Not secret, but writing them changes what runs outside the worker's control. */
|
|
26
|
-
const WRITE_DENY_SEGMENTS = [".git", ".agenthub"];
|
|
27
|
-
|
|
28
26
|
const lexists = (p: string) => {
|
|
29
27
|
try {
|
|
30
28
|
lstatSync(p);
|
|
@@ -52,7 +50,9 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string,
|
|
|
52
50
|
const rel = relative(root, real);
|
|
53
51
|
if (rel.startsWith("..") || resolve(root, rel) !== real) throw new Error(`${path} is outside the project directory`);
|
|
54
52
|
if (isDenied(rel, ctx.deny)) throw new Error(`${path} is on the secrets denylist`);
|
|
55
|
-
|
|
53
|
+
// Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
|
|
54
|
+
// folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
|
|
55
|
+
if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
|
|
56
56
|
return real;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -129,23 +129,27 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
129
129
|
}
|
|
130
130
|
case "write": {
|
|
131
131
|
let file = guardPath(ctx, String(a.path), "write");
|
|
132
|
+
const approvedTarget = file;
|
|
132
133
|
const content = String(a.content ?? "");
|
|
133
|
-
if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}
|
|
134
|
+
if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file))) return "error: the user did not approve this write";
|
|
134
135
|
if (ctx.signal?.aborted) return "error: turn cancelled before write";
|
|
135
136
|
file = guardPath(ctx, String(a.path), "write");
|
|
137
|
+
if (file !== approvedTarget) return "error: path target changed during approval; inspect before retrying";
|
|
136
138
|
mkdirSync(dirname(file), { recursive: true });
|
|
137
139
|
writeFileSync(file, content);
|
|
138
140
|
return `wrote ${a.path}`;
|
|
139
141
|
}
|
|
140
142
|
case "edit": {
|
|
141
143
|
let file = guardPath(ctx, String(a.path), "write");
|
|
144
|
+
const approvedTarget = file;
|
|
142
145
|
let text = readFileSync(file, "utf8");
|
|
143
146
|
let count = text.split(String(a.old)).length - 1;
|
|
144
147
|
if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
|
|
145
|
-
if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}
|
|
148
|
+
if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file))) return "error: the user did not approve this edit";
|
|
146
149
|
if (ctx.signal?.aborted) return "error: turn cancelled before edit";
|
|
147
150
|
// Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
|
|
148
151
|
file = guardPath(ctx, String(a.path), "write");
|
|
152
|
+
if (file !== approvedTarget) return "error: path target changed during approval; inspect before retrying";
|
|
149
153
|
text = readFileSync(file, "utf8");
|
|
150
154
|
count = text.split(String(a.old)).length - 1;
|
|
151
155
|
if (count !== 1) return `error: file changed during approval; \`old\` must match exactly once, it matched ${count} times`;
|
|
@@ -157,7 +161,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
157
161
|
if (!command.trim()) return "error: empty command";
|
|
158
162
|
// The approver sees the whole command, not a prefix: what is hidden cannot be approved.
|
|
159
163
|
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
160
|
-
if (!(await ctx.permit(`bash: ${command}
|
|
164
|
+
if (!(await ctx.permit(`bash: ${command}`, "bash", ctx.signal))) return "error: the user did not approve this command";
|
|
161
165
|
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
162
166
|
return `${res.output}\n(exit ${res.code})`;
|
|
163
167
|
}
|
|
@@ -167,7 +171,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
167
171
|
if (!GIT_READ.has(sub) && !GIT_WRITE.has(sub)) return `error: git ${sub} is not available to the local worker`;
|
|
168
172
|
const problem = gitArgsProblem(args, ctx.deny);
|
|
169
173
|
if (problem) return `error: git ${sub}: ${problem}`;
|
|
170
|
-
if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}
|
|
174
|
+
if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`, "git", ctx.signal))) return "error: the user did not approve this git command";
|
|
171
175
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
172
176
|
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
173
177
|
return `${res.output}\n(exit ${res.code})`;
|
package/src/models/mlx.ts
CHANGED
|
@@ -48,7 +48,8 @@ export interface MlxHandle {
|
|
|
48
48
|
readonly status: () => MlxStatus;
|
|
49
49
|
readonly url: string;
|
|
50
50
|
readonly model: string;
|
|
51
|
-
|
|
51
|
+
/** `waitMs` bounds the wait for a free generation slot (default 120 s); past it, `MlxBusyError`. */
|
|
52
|
+
readonly acquire: (signal?: AbortSignal, waitMs?: number) => Promise<() => void>;
|
|
52
53
|
readonly close: () => Promise<void>;
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -177,6 +178,8 @@ interface GenerationOwner {
|
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
class GenerationSlotBusy extends Error {}
|
|
181
|
+
/** Every generation slot stayed taken for the whole wait. */
|
|
182
|
+
export class MlxBusyError extends Error {}
|
|
180
183
|
|
|
181
184
|
function generationDatabase(runtimeDir: string): Database {
|
|
182
185
|
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
@@ -239,10 +242,10 @@ function claimGenerationSlot(db: Database, slot: number, readInfo: (pid: number)
|
|
|
239
242
|
};
|
|
240
243
|
}
|
|
241
244
|
|
|
242
|
-
export async function acquireGeneration(runtimeDir: string, maxConcurrency: number, signal?: AbortSignal, readInfo: (pid: number) => ProcessSignature | undefined = processInfo): Promise<() => void> {
|
|
245
|
+
export async function acquireGeneration(runtimeDir: string, maxConcurrency: number, signal?: AbortSignal, readInfo: (pid: number) => ProcessSignature | undefined = processInfo, waitMs = 120_000): Promise<() => void> {
|
|
243
246
|
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) throw new Error("MLX maxConcurrency must be a positive integer");
|
|
244
247
|
if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
|
|
245
|
-
const deadline = Date.now() +
|
|
248
|
+
const deadline = Date.now() + waitMs;
|
|
246
249
|
const db = generationDatabase(runtimeDir);
|
|
247
250
|
try {
|
|
248
251
|
for (;;) {
|
|
@@ -251,7 +254,7 @@ export async function acquireGeneration(runtimeDir: string, maxConcurrency: numb
|
|
|
251
254
|
if (release) return release;
|
|
252
255
|
}
|
|
253
256
|
if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
|
|
254
|
-
if (Date.now() >= deadline) throw new
|
|
257
|
+
if (Date.now() >= deadline) throw new MlxBusyError("MLX generation is busy");
|
|
255
258
|
await new Promise<void>((resolve, reject) => {
|
|
256
259
|
let settled = false;
|
|
257
260
|
const finish = (error?: Error) => {
|
|
@@ -359,8 +362,8 @@ export async function ensureMlx(options: MlxOptions = {}): Promise<MlxHandle> {
|
|
|
359
362
|
}
|
|
360
363
|
const sharedHandle = (status: MlxStatus): MlxHandle => {
|
|
361
364
|
let active = 0;
|
|
362
|
-
return { url: status.url!, model: modelPath, status: () => ({ ...status, active }), acquire: async (signal) => {
|
|
363
|
-
const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo);
|
|
365
|
+
return { url: status.url!, model: modelPath, status: () => ({ ...status, active }), acquire: async (signal, waitMs) => {
|
|
366
|
+
const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo, waitMs);
|
|
364
367
|
active++;
|
|
365
368
|
return () => { active = Math.max(0, active - 1); releaseSlot(); };
|
|
366
369
|
}, close: async () => {} };
|
|
@@ -408,8 +411,8 @@ export async function ensureMlx(options: MlxOptions = {}): Promise<MlxHandle> {
|
|
|
408
411
|
// The server is machine-shared. Relay shutdown releases this process's handle;
|
|
409
412
|
// only the explicit stopMlx operation may stop the owned runtime.
|
|
410
413
|
const close = async () => {};
|
|
411
|
-
return { url, model: modelPath, status: () => ({ state: "ready", url, model: modelPath, pid: child?.pid, maxInputTokens, maxConcurrency, active }), acquire: async (signal) => {
|
|
412
|
-
const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo);
|
|
414
|
+
return { url, model: modelPath, status: () => ({ state: "ready", url, model: modelPath, pid: child?.pid, maxInputTokens, maxConcurrency, active }), acquire: async (signal, waitMs) => {
|
|
415
|
+
const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo, waitMs);
|
|
413
416
|
active++;
|
|
414
417
|
return () => { active = Math.max(0, active - 1); releaseSlot(); };
|
|
415
418
|
}, close };
|
package/src/models/ollama.ts
CHANGED
|
@@ -142,8 +142,8 @@ export async function ensureOllama(options: MlxOptions = {}): Promise<MlxHandle>
|
|
|
142
142
|
url: `${baseUrl(config)}/v1`,
|
|
143
143
|
model: config.model,
|
|
144
144
|
status,
|
|
145
|
-
acquire: async (signal) => {
|
|
146
|
-
const releaseSlot = await acquireGeneration(config.runtimeDir, config.maxConcurrency, signal);
|
|
145
|
+
acquire: async (signal, waitMs) => {
|
|
146
|
+
const releaseSlot = await acquireGeneration(config.runtimeDir, config.maxConcurrency, signal, undefined, waitMs);
|
|
147
147
|
try {
|
|
148
148
|
const current = await inspectOllama(options);
|
|
149
149
|
if (signal?.aborted) throw new Error("Ollama generation was cancelled");
|
package/src/models/relay.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
3
|
import type { OmniRoute, ChatMessage } from "../omniroute/client.ts";
|
|
4
|
-
import { ensureMlx, type MlxHandle, type MlxOptions, type MlxStatus } from "./mlx.ts";
|
|
5
|
-
import { AutoRouteSelector } from "./route/relay-selector.ts";
|
|
4
|
+
import { ensureMlx, MlxBusyError, type MlxHandle, type MlxOptions, type MlxStatus } from "./mlx.ts";
|
|
5
|
+
import { AutoRouteSelector, type AutoChoice, type RelayRouteEvent } from "./route/relay-selector.ts";
|
|
6
|
+
import { estimateInputTokens, type StaySwitchPolicy } from "./route/stage.ts";
|
|
6
7
|
|
|
7
8
|
export type ModelBackend = { kind: "mlx"; alias?: string } | { kind: "dgx"; alias: string };
|
|
8
9
|
|
|
@@ -23,6 +24,11 @@ export interface RelayBackendStatus {
|
|
|
23
24
|
provider?: string;
|
|
24
25
|
active: number;
|
|
25
26
|
lastError?: string;
|
|
27
|
+
/** While the alias cools down after consecutive transport or startup failures (#199). */
|
|
28
|
+
coolingUntil?: string;
|
|
29
|
+
failures?: number;
|
|
30
|
+
/** While its last dispatch counts as failed: no load move goes to it. */
|
|
31
|
+
failingUntil?: string;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export interface ModelRelayStatus {
|
|
@@ -153,7 +159,22 @@ export interface ModelRelayOptions {
|
|
|
153
159
|
enableHubAuto?: boolean;
|
|
154
160
|
/** Trusted host callback. Requests without a stable session key get stateless stage selection. */
|
|
155
161
|
routeSessionKey?: (request: RelayRequest) => string | undefined;
|
|
156
|
-
onRoute?: (event:
|
|
162
|
+
onRoute?: (event: RelayRouteEvent) => void;
|
|
163
|
+
/** Read on every `hub/auto` call (#197); absent is the shadow default. */
|
|
164
|
+
staySwitch?: () => StaySwitchPolicy | undefined;
|
|
165
|
+
/** Opt-in (#199): how long a `hub/auto` request waits for a busy MLX slot before it moves to its fallback. Absent, no
|
|
166
|
+
* load move or enforced-pin move happens; cooldown reorders still do. */
|
|
167
|
+
efficientWaitMs?: number;
|
|
168
|
+
/** A backend alias's cooldown starts or ends. */
|
|
169
|
+
onCooldown?: (event: RelayCooldownEvent) => void;
|
|
170
|
+
/** Cooldown clock; tests inject one. */
|
|
171
|
+
now?: () => number;
|
|
172
|
+
/** How long a dispatch waits for a busy MLX slot before it gives up and falls back (default 120 s); tests shorten it. */
|
|
173
|
+
slotWaitMs?: number;
|
|
174
|
+
/** A moved attempt's deadline for its response headers (default 15 s); tests shorten it. */
|
|
175
|
+
moveFirstByteMs?: number;
|
|
176
|
+
/** Whether the request runs under an execution budget, read without admitting anything; then nothing moves (#199). */
|
|
177
|
+
underBudget?: () => boolean;
|
|
157
178
|
allowedDGXmodels: Record<string, string>;
|
|
158
179
|
/** Trusted physical model expectations by backend alias. Gateway identifiers can include a provider
|
|
159
180
|
* prefix or route name that differs from the model reported by generation. Omission preserves the
|
|
@@ -184,11 +205,85 @@ export interface ModelRelay {
|
|
|
184
205
|
readonly close: () => Promise<void>;
|
|
185
206
|
}
|
|
186
207
|
|
|
208
|
+
export interface RelayCooldownEvent { alias: string; event: "start" | "end"; failures: number; ms?: number }
|
|
209
|
+
|
|
210
|
+
// ponytail: fixed policy, 3 failures and 30 s doubling up to 5 min; routing.toml keys if a backend needs other values.
|
|
211
|
+
const COOLDOWN_FAILURES = 3, COOLDOWN_MS = 30_000, COOLDOWN_CAP_MS = 300_000;
|
|
212
|
+
/** The longest one upstream request may take. */
|
|
213
|
+
const REQUEST_DEADLINE_MS = 180_000;
|
|
214
|
+
// ponytail: a moved attempt (a load move or an enforced pin, both only optimizations) gets 15 s to its response headers
|
|
215
|
+
// and is then abandoned for MLX; tune it from the #199 AC5 measurement.
|
|
216
|
+
const MOVE_FIRST_BYTE_MS = 15_000;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Per-alias health in the relay (#199). A cooldown follows consecutive transport or startup failures, and any HTTP
|
|
220
|
+
* answer, a success included, ends it. Separately, a backend whose last dispatch failed in any way (an error status too)
|
|
221
|
+
* gets no load move for COOLDOWN_MS or until it succeeds.
|
|
222
|
+
*/
|
|
223
|
+
export class BackendCooldowns {
|
|
224
|
+
private readonly entries = new Map<string, { failures: number; until?: number; failedAt?: number; countedAt?: number }>();
|
|
225
|
+
constructor(private readonly now: () => number = Date.now, private readonly notify: (event: RelayCooldownEvent) => void = () => {}) {}
|
|
226
|
+
|
|
227
|
+
/** The alias's cooldown while it lasts. Its end is recorded when it is first seen to have passed, or at an answer. */
|
|
228
|
+
cooling(alias: string): { until: number; failures: number } | undefined {
|
|
229
|
+
const entry = this.entries.get(alias);
|
|
230
|
+
if (entry?.until === undefined) return undefined;
|
|
231
|
+
if (this.now() < entry.until) return { until: entry.until, failures: entry.failures };
|
|
232
|
+
entry.until = undefined;
|
|
233
|
+
this.notify({ alias, event: "end", failures: entry.failures });
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A failed dispatch. `streak`: a transport or startup failure, which counts toward a cooldown unless one is running (a
|
|
239
|
+
* request already in flight, or a cooling alias tried as a fallback); otherwise it only marks the alias `failing`. A
|
|
240
|
+
* streak whose last counted failure is more than twice the cap old starts over, so an old cooldown does not double.
|
|
241
|
+
*/
|
|
242
|
+
failed(alias: string, streak = true): void {
|
|
243
|
+
const cooling = this.cooling(alias);
|
|
244
|
+
const entry = this.entry(alias);
|
|
245
|
+
entry.failedAt = this.now();
|
|
246
|
+
if (cooling || !streak) return;
|
|
247
|
+
if (entry.countedAt !== undefined && this.now() - entry.countedAt > 2 * COOLDOWN_CAP_MS) entry.failures = 0;
|
|
248
|
+
entry.countedAt = this.now();
|
|
249
|
+
entry.failures++;
|
|
250
|
+
if (entry.failures < COOLDOWN_FAILURES) return;
|
|
251
|
+
const ms = Math.min(COOLDOWN_CAP_MS, COOLDOWN_MS * 2 ** (entry.failures - COOLDOWN_FAILURES));
|
|
252
|
+
entry.until = this.now() + ms;
|
|
253
|
+
this.notify({ alias, event: "start", failures: entry.failures, ms });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The backend answered: its transport works, so the failure streak and any cooldown end. Only a success clears `failing`. */
|
|
257
|
+
answered(alias: string, ok: boolean): void {
|
|
258
|
+
const entry = this.entry(alias);
|
|
259
|
+
if (entry.until !== undefined) this.notify({ alias, event: "end", failures: entry.failures });
|
|
260
|
+
entry.failures = 0;
|
|
261
|
+
entry.until = undefined;
|
|
262
|
+
entry.failedAt = ok ? undefined : this.now();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Until when the alias's last dispatch counts as failed for a load move, while it does. */
|
|
266
|
+
failing(alias: string): number | undefined {
|
|
267
|
+
const at = this.entries.get(alias)?.failedAt;
|
|
268
|
+
return at !== undefined && this.now() < at + COOLDOWN_MS ? at + COOLDOWN_MS : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private entry(alias: string) {
|
|
272
|
+
let entry = this.entries.get(alias);
|
|
273
|
+
if (!entry) this.entries.set(alias, entry = { failures: 0 });
|
|
274
|
+
return entry;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type Probe = { release: () => void } | { error: unknown };
|
|
279
|
+
|
|
187
280
|
interface RequestJournalEntry {
|
|
188
281
|
readonly record: RelayRequestRecord;
|
|
189
282
|
identify(model: string, source: "header" | "stream" | "configured"): void;
|
|
190
283
|
usage(value: unknown): void;
|
|
191
284
|
close(outcome: RelayRequestRecord["outcome"]): void;
|
|
285
|
+
/** The relay's own deadline cut the attempt: the execution budget's, or a moved attempt's first-byte one. No cooldown count. */
|
|
286
|
+
cut?: "budget" | "move";
|
|
192
287
|
}
|
|
193
288
|
|
|
194
289
|
interface ActiveRequest {
|
|
@@ -225,10 +320,6 @@ function assertLoopback(host: string): void {
|
|
|
225
320
|
if (value !== "localhost" && value !== "::1" && !(isIP(value) === 4 && value.startsWith("127."))) throw new Error("model relay host must be loopback");
|
|
226
321
|
}
|
|
227
322
|
|
|
228
|
-
function estimateInputTokens(messages: ChatMessage[], tools?: unknown[]): number {
|
|
229
|
-
return Math.ceil(JSON.stringify({ messages, tools }).length / 4);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
323
|
function bearer(request: Request): string | undefined {
|
|
233
324
|
const value = request.headers.get("authorization");
|
|
234
325
|
return value?.startsWith("Bearer ") ? value.slice("Bearer ".length) : undefined;
|
|
@@ -337,7 +428,10 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
337
428
|
const dgxMaxInputTokens = options.dgxMaxInputTokens ?? 262_144;
|
|
338
429
|
const defaultBackend = options.defaultBackend ?? (options.mlx ? { kind: "mlx", alias: mlxAlias } : { kind: "dgx", alias: "dgx/coding" });
|
|
339
430
|
const models = relayModelIds(options);
|
|
340
|
-
const
|
|
431
|
+
const efficientWaitMs = options.efficientWaitMs;
|
|
432
|
+
const moveFirstByteMs = options.moveFirstByteMs ?? MOVE_FIRST_BYTE_MS;
|
|
433
|
+
const cooldowns = new BackendCooldowns(options.now, (event) => { try { options.onCooldown?.(event); } catch { /* observation cannot fail routing */ } });
|
|
434
|
+
const autoRoute = options.enableHubAuto ? new AutoRouteSelector({ ...options, dgxMaxInputTokens }, defaultBackend, mlxAlias, estimateInputTokens) : undefined;
|
|
341
435
|
let mlx: MlxHandle | undefined;
|
|
342
436
|
let mlxStarting: Promise<MlxHandle> | undefined;
|
|
343
437
|
const states = new Map<string, RelayBackendStatus>();
|
|
@@ -403,20 +497,22 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
403
497
|
states.set(alias, { ...state(backend), ...patch, alias, kind: backend.kind });
|
|
404
498
|
};
|
|
405
499
|
|
|
406
|
-
const resolve = async (body: RelayRequest): Promise<ModelBackend> => {
|
|
500
|
+
const resolve = async (body: RelayRequest): Promise<{ backend: ModelBackend; auto?: AutoChoice }> => {
|
|
407
501
|
const requested = typeof body.model === "string" ? body.model : undefined;
|
|
408
502
|
const automatic = options.enableHubAuto === true && requested === "hub/auto";
|
|
409
503
|
if (requested && !models.includes(requested)) throw new Error("model alias is not allowed");
|
|
410
|
-
if (requested === mlxAlias && options.mlx) return { kind: "mlx", alias: mlxAlias };
|
|
411
|
-
if (requested && requested in options.allowedDGXmodels) return { kind: "dgx", alias: requested };
|
|
412
|
-
if (automatic)
|
|
504
|
+
if (requested === mlxAlias && options.mlx) return { backend: { kind: "mlx", alias: mlxAlias } };
|
|
505
|
+
if (requested && requested in options.allowedDGXmodels) return { backend: { kind: "dgx", alias: requested } };
|
|
506
|
+
if (automatic) { const auto = await autoRoute!.select(body); return { backend: auto.backend, auto }; }
|
|
413
507
|
const selected = options.selectBackend ? await options.selectBackend(body) : defaultBackend;
|
|
414
508
|
if (selected.kind === "mlx" && !options.mlx) throw new Error("MLX backend is not configured");
|
|
415
509
|
if (selected.kind === "dgx" && !(selected.alias in options.allowedDGXmodels)) throw new Error("DGX model alias is not allowed");
|
|
416
|
-
return selected;
|
|
510
|
+
return { backend: selected };
|
|
417
511
|
};
|
|
418
512
|
|
|
419
|
-
|
|
513
|
+
/** `pre`: the load probe's outcome for MLX, a held generation slot (released exactly once by whoever ends the request)
|
|
514
|
+
* or the error it met, reported here without asking MLX again. */
|
|
515
|
+
const upstream = async (request: RelayRequest, backend: ModelBackend, signal: AbortSignal, journalEntry: RequestJournalEntry, pre?: Probe, firstByteMs?: number): Promise<{ response: Response; release: () => void; onModel?: (model: string) => void }> => {
|
|
420
516
|
const alias = aliasOf(backend, mlxAlias);
|
|
421
517
|
let base: string;
|
|
422
518
|
let model: string;
|
|
@@ -436,7 +532,8 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
436
532
|
if (options.mlx?.provider === "ollama" && options.mlxModel && options.mlxModel !== handle.model) throw new Error("Ollama model override does not match the validated model");
|
|
437
533
|
model = options.mlxModel ?? handle.model;
|
|
438
534
|
if (signal.aborted) throw new Error("request was cancelled before MLX generation started");
|
|
439
|
-
|
|
535
|
+
if (pre && "error" in pre) throw pre.error;
|
|
536
|
+
release = pre?.release ?? await handle.acquire(signal, options.slotWaitMs);
|
|
440
537
|
} else {
|
|
441
538
|
base = (await options.omni.base()) ?? (() => { throw new Error("DGX gateway is unavailable"); })();
|
|
442
539
|
model = options.allowedDGXmodels[backend.alias]!;
|
|
@@ -455,6 +552,9 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
455
552
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
456
553
|
if (key) Object.assign(headers, { authorization: `Bearer ${key}` }, options.omni.accessHeaders(base));
|
|
457
554
|
let response: Response;
|
|
555
|
+
let budgetDeadline: AbortSignal | undefined;
|
|
556
|
+
const firstByte = firstByteMs === undefined ? undefined : new AbortController();
|
|
557
|
+
let firstByteTimer: ReturnType<typeof setTimeout> | undefined;
|
|
458
558
|
try {
|
|
459
559
|
const isOllama = backend.kind === "mlx" && options.mlx?.provider === "ollama";
|
|
460
560
|
const boundedRequest = isOllama
|
|
@@ -462,14 +562,19 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
462
562
|
: request;
|
|
463
563
|
const decision = await options.admitRequest?.();
|
|
464
564
|
if (decision && !decision.allowed) throw new ExecutionAdmissionError(decision.reason ?? "execution budget exhausted");
|
|
465
|
-
const deadline = decision?.remainingMs === undefined ?
|
|
466
|
-
|
|
565
|
+
const deadline = decision?.remainingMs === undefined ? REQUEST_DEADLINE_MS : Math.max(1, Math.min(REQUEST_DEADLINE_MS, decision.remainingMs));
|
|
566
|
+
const timeout = AbortSignal.timeout(deadline);
|
|
567
|
+
if (deadline < REQUEST_DEADLINE_MS) budgetDeadline = timeout;
|
|
568
|
+
if (firstByte) firstByteTimer = setTimeout(() => firstByte.abort(new Error(`moved attempt had no answer within ${firstByteMs} ms`)), firstByteMs);
|
|
569
|
+
response = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, { method: "POST", ...(isOllama ? { redirect: "error" as const } : {}), headers, body: JSON.stringify(bodyForUpstream(boundedRequest, model)), signal: AbortSignal.any([signal, timeout, ...(firstByte ? [firstByte.signal] : [])]) });
|
|
467
570
|
} catch (error) {
|
|
468
571
|
releaseOnce();
|
|
469
572
|
journalEntry.record.failureClass = "transport";
|
|
573
|
+
if (firstByte?.signal.aborted) journalEntry.cut = "move";
|
|
574
|
+
else if (budgetDeadline?.aborted) journalEntry.cut = "budget";
|
|
470
575
|
setState(backend, { state: "error", lastError: error instanceof Error ? error.message.slice(0, 160) : "upstream request failed" });
|
|
471
576
|
throw error;
|
|
472
|
-
}
|
|
577
|
+
} finally { clearTimeout(firstByteTimer); }
|
|
473
578
|
const provider = safeHeader(response.headers.get("x-omniroute-provider"));
|
|
474
579
|
if (provider) {
|
|
475
580
|
journalEntry.record.provider = provider;
|
|
@@ -524,32 +629,65 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
524
629
|
activeRequests.delete(record);
|
|
525
630
|
return Response.json({ error: error instanceof Error ? error.message : "invalid request" }, { status: 400 });
|
|
526
631
|
}
|
|
527
|
-
let backend: ModelBackend;
|
|
528
|
-
try { backend = await resolve(body); } catch (error) {
|
|
632
|
+
let backend: ModelBackend, auto: AutoChoice | undefined;
|
|
633
|
+
try { ({ backend, auto } = await resolve(body)); } catch (error) {
|
|
529
634
|
record.cleanup();
|
|
530
635
|
activeRequests.delete(record);
|
|
531
636
|
return Response.json({ error: error instanceof Error ? error.message : "backend unavailable" }, { status: 400 });
|
|
532
637
|
}
|
|
533
|
-
const
|
|
638
|
+
const own = backend;
|
|
639
|
+
// A refused request still records the decision, unmoved; a dispatched one records it after the ordering step.
|
|
640
|
+
const emitRoute = () => { if (auto) try { options.onRoute?.(auto.route); } catch { /* observation cannot fail routing */ } };
|
|
534
641
|
const inputTokens = estimateInputTokens(body.messages, body.tools);
|
|
535
|
-
const
|
|
642
|
+
const inputBudget = own.kind === "mlx" ? (options.mlx?.maxInputTokens ?? (options.mlx?.provider === "ollama" ? 6000 : 16_000)) : dgxMaxInputTokens;
|
|
643
|
+
const ollama = own.kind === "mlx" && options.mlx?.provider === "ollama";
|
|
536
644
|
const contextWindow = ollama ? (options.mlx?.contextWindow ?? 8192) : undefined;
|
|
537
645
|
const configuredMaxTokens = ollama ? (options.mlx?.maxTokens ?? 2048) : undefined;
|
|
538
646
|
const requestedMaxTokens = body.max_tokens === undefined ? configuredMaxTokens : body.max_tokens;
|
|
539
647
|
if (ollama && (!Number.isInteger(requestedMaxTokens) || (requestedMaxTokens as number) < 1 || (requestedMaxTokens as number) > configuredMaxTokens! || inputTokens + (requestedMaxTokens as number) > contextWindow!)) {
|
|
648
|
+
emitRoute();
|
|
540
649
|
record.cleanup();
|
|
541
650
|
activeRequests.delete(record);
|
|
542
651
|
return Response.json({ error: "input and max_tokens exceed the Ollama context budget" }, { status: 400 });
|
|
543
652
|
}
|
|
544
653
|
if (inputTokens > inputBudget) {
|
|
654
|
+
emitRoute();
|
|
545
655
|
record.cleanup();
|
|
546
656
|
activeRequests.delete(record);
|
|
547
657
|
return Response.json({ error: "input exceeds the model context budget" }, { status: 400 });
|
|
548
658
|
}
|
|
549
|
-
|
|
659
|
+
// #199: one ordering step, for hub/auto only (a fixed alias keeps its backend). The candidates are the route's own
|
|
660
|
+
// backend and, for MLX, its DGX fallback, exactly as before; a cooldown, an enforced pin or a busy MLX slot only
|
|
661
|
+
// reorders them. Nothing goes ahead to a fallback that is failing or cooling down.
|
|
662
|
+
const candidates: ModelBackend[] = [own, ...(own.kind === "mlx" && options.fallbackDGXAlias ? [{ kind: "dgx", alias: options.fallbackDGXAlias } as ModelBackend] : [])];
|
|
663
|
+
const second = candidates[1];
|
|
664
|
+
let moved: "optional" | "cooldown" | undefined;
|
|
665
|
+
const swap = (source: "load" | "cooldown" | "pin") => { candidates.reverse(); moved = source === "cooldown" ? "cooldown" : "optional"; auto?.moved(aliasOf(candidates[0]!, mlxAlias), source); };
|
|
666
|
+
// A load move or pin is only an optimization: opt-in (`efficientWaitMs`) until #199 AC5 measures it, bounded by its
|
|
667
|
+
// first-byte deadline, and never under an execution budget, where it could spend the model call or the time MLX
|
|
668
|
+
// needs after it (a throw counts as a budget).
|
|
669
|
+
// ponytail: no moves at all under a budget; budget-aware moves from admitPiRequest's remaining units are the upgrade.
|
|
670
|
+
let room = efficientWaitMs !== undefined;
|
|
671
|
+
try { room &&= !options.underBudget?.(); } catch { room = false; }
|
|
672
|
+
let pre: Probe | undefined;
|
|
673
|
+
if (auto && second && !cooldowns.failing(aliasOf(second, mlxAlias)) && !cooldowns.cooling(aliasOf(second, mlxAlias))) {
|
|
674
|
+
if (cooldowns.cooling(aliasOf(own, mlxAlias))) swap("cooldown");
|
|
675
|
+
else if (auto.prefer === aliasOf(second, mlxAlias) && room) swap("pin");
|
|
676
|
+
else if (auto.movable && mlx && room) {
|
|
677
|
+
try {
|
|
678
|
+
const held = await mlx.acquire(controller.signal, efficientWaitMs);
|
|
679
|
+
let holding = true;
|
|
680
|
+
pre = { release: () => { if (holding) { holding = false; held(); } } };
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (error instanceof MlxBusyError) swap("load");
|
|
683
|
+
else pre = { error }; // the MLX dispatch reports it, without inspecting Ollama a second time
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
emitRoute();
|
|
550
688
|
const dispatchGroupId = randomUUID();
|
|
551
689
|
let primaryDispatchId: string | undefined;
|
|
552
|
-
const dispatch = async (selected: ModelBackend, body: RelayRequest) => {
|
|
690
|
+
const dispatch = async (selected: ModelBackend, body: RelayRequest, pre?: Probe, firstByteMs?: number) => {
|
|
553
691
|
const journalEntry = openRequestRecord(aliasOf(selected, mlxAlias), dispatchGroupId);
|
|
554
692
|
// The surface is a property of the request as admitted: even a failed dispatch keeps what the
|
|
555
693
|
// native published. A request without a tools array carries no observation, never an empty one.
|
|
@@ -565,11 +703,21 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
565
703
|
record.closeRecord = journalEntry.close;
|
|
566
704
|
let result: Awaited<ReturnType<typeof upstream>>;
|
|
567
705
|
try {
|
|
568
|
-
result = await upstream(body, selected, controller.signal, journalEntry);
|
|
706
|
+
result = await upstream(body, selected, controller.signal, journalEntry, pre, firstByteMs);
|
|
569
707
|
} catch (error) {
|
|
708
|
+
if (pre && "release" in pre) pre.release();
|
|
570
709
|
journalEntry.record.failureClass ??= controller.signal.aborted ? "cancelled" : error instanceof ExecutionAdmissionError ? "admission" : "startup";
|
|
710
|
+
const failure = journalEntry.record.failureClass;
|
|
711
|
+
// A busy MLX slot is load, never a failure; neither is a cancelled request, an admission refusal or a budget cut.
|
|
712
|
+
if (!controller.signal.aborted && !journalEntry.cut && !(error instanceof ExecutionAdmissionError) && !(error instanceof MlxBusyError) && (failure === "transport" || failure === "startup")) {
|
|
713
|
+
// A DGX alias can fail before it has a status row (gateway or key unavailable); a cooldown must still show.
|
|
714
|
+
if (!states.has(journalEntry.record.alias)) setState(selected, { state: "error", lastError: error instanceof Error ? error.message.slice(0, 160) : "backend unavailable" });
|
|
715
|
+
cooldowns.failed(journalEntry.record.alias);
|
|
716
|
+
} else if (journalEntry.cut === "move") cooldowns.failed(journalEntry.record.alias, false); // slow, not unreachable
|
|
717
|
+
else if (failure === "http") cooldowns.answered(journalEntry.record.alias, false);
|
|
571
718
|
throw error;
|
|
572
719
|
}
|
|
720
|
+
cooldowns.answered(journalEntry.record.alias, true);
|
|
573
721
|
let released = false;
|
|
574
722
|
const release = () => {
|
|
575
723
|
if (released) return;
|
|
@@ -582,30 +730,30 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
582
730
|
const onUsage = journalEntry.usage;
|
|
583
731
|
return { result, release, journalEntry, onUsage };
|
|
584
732
|
};
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; }, journalEntry.close, onUsage);
|
|
588
|
-
} catch (error) {
|
|
589
|
-
record.closeRecord?.(controller.signal.aborted ? "cancelled" : "failed");
|
|
590
|
-
if (!fallback || controller.signal.aborted || error instanceof ExecutionAdmissionError) {
|
|
591
|
-
record.cleanup();
|
|
592
|
-
activeRequests.delete(record);
|
|
593
|
-
return Response.json({ error: error instanceof Error ? error.message : "backend unavailable" }, { status: 502 });
|
|
594
|
-
}
|
|
733
|
+
let lastError: unknown;
|
|
734
|
+
for (const [i, candidate] of candidates.entries()) {
|
|
595
735
|
try {
|
|
596
|
-
const { result, release, journalEntry, onUsage } = await dispatch(
|
|
597
|
-
|
|
598
|
-
|
|
736
|
+
const { result, release, journalEntry, onUsage } = await dispatch(candidate, i ? { ...body, model: aliasOf(candidate, mlxAlias) } : body, i ? undefined : pre, i === 0 && moved === "optional" ? moveFirstByteMs : undefined);
|
|
737
|
+
// A stream that fails after its headers cannot fall back; it still marks the alias failing, so no move follows. A
|
|
738
|
+
// client that went away can fail the pending read before the cancel arrives: that says nothing about the backend.
|
|
739
|
+
const close = (outcome: RelayRequestRecord["outcome"]) => { journalEntry.close(outcome); if (outcome === "failed" && !controller.signal.aborted) cooldowns.failed(journalEntry.record.alias, false); };
|
|
740
|
+
return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; }, close, onUsage);
|
|
741
|
+
} catch (error) {
|
|
599
742
|
record.closeRecord?.(controller.signal.aborted ? "cancelled" : "failed");
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
return Response.json({ error: fallbackError instanceof Error ? fallbackError.message : "fallback unavailable" }, { status: 502 });
|
|
743
|
+
lastError = error;
|
|
744
|
+
if (controller.signal.aborted || error instanceof ExecutionAdmissionError) break;
|
|
603
745
|
}
|
|
604
746
|
}
|
|
747
|
+
record.cleanup();
|
|
748
|
+
activeRequests.delete(record);
|
|
749
|
+
return Response.json({ error: lastError instanceof Error ? lastError.message : "backend unavailable" }, { status: 502 });
|
|
605
750
|
},
|
|
606
751
|
});
|
|
607
752
|
const url = `http://${host}:${server.port}/v1`;
|
|
608
|
-
const status = (): ModelRelayStatus => ({ url, models, backends: [...states.values()].map((value) =>
|
|
753
|
+
const status = (): ModelRelayStatus => ({ url, models, backends: [...states.values()].map((value) => {
|
|
754
|
+
const cooling = cooldowns.cooling(value.alias), failing = cooldowns.failing(value.alias);
|
|
755
|
+
return { ...value, ...(cooling ? { coolingUntil: new Date(cooling.until).toISOString(), failures: cooling.failures } : {}), ...(failing ? { failingUntil: new Date(failing).toISOString() } : {}) };
|
|
756
|
+
}) });
|
|
609
757
|
const requests = (): RelayRequestRecord[] => journal.map(copyRecord);
|
|
610
758
|
return { url, token, models, status, requests, close: async () => {
|
|
611
759
|
const closing = [...activeRequests].map(async (request) => {
|