@staix/agent-hub 0.12.18 → 0.12.20
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 +21 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +2 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/events.md +4 -1
- package/docs/operations.md +419 -17
- package/docs/quickstart.md +1 -1
- package/docs/security.md +18 -11
- package/docs/smoke.md +91 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +412 -3
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +63 -25
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +3 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/console-state.ts +325 -102
- package/src/cli/console.ts +44 -23
- package/src/cli/help.ts +120 -0
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +2 -0
- package/src/cli/main.ts +171 -75
- package/src/cli/preview.ts +1 -0
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +9 -5
- package/src/cli/terminal-recovery.ts +140 -36
- package/src/cli/upgrade-runtime.ts +274 -56
- package/src/cli/upgrade.ts +366 -41
- package/src/hub/ask.ts +1 -1
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +60 -4
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +38 -6
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +70 -12
- package/src/hub/events.ts +7 -1
- 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 +7 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +51 -3
- package/src/hub/report.ts +43 -1
- 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 +236 -83
- package/src/local/deny.ts +2 -1
- 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/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
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/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) => {
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import type { ModelBackend, ModelRelayOptions, RelayRequest } from "../relay.ts";
|
|
2
2
|
import { normalizeConversation } from "./normalize.ts";
|
|
3
|
-
import { extractToolSignals } from "./signals.ts";
|
|
4
|
-
import { selectStage, type StageState } from "./stage.ts";
|
|
3
|
+
import { extractToolSignals, turnKind } from "./signals.ts";
|
|
4
|
+
import { selectStage, stayOrSwitch, type StageState, type SwitchTrace, type Tier } from "./stage.ts";
|
|
5
5
|
import { SessionState } from "./state.ts";
|
|
6
6
|
|
|
7
|
-
type RelaySelectorOptions = Pick<ModelRelayOptions, "mlx" | "selectBackend" | "routeSessionKey" | "
|
|
8
|
-
type RouteSource = "override" | "dimensions" | "hold" | "classifier" | "default";
|
|
9
|
-
|
|
7
|
+
type RelaySelectorOptions = Pick<ModelRelayOptions, "mlx" | "selectBackend" | "routeSessionKey" | "allowedDGXmodels" | "staySwitch"> & { dgxMaxInputTokens: number };
|
|
8
|
+
type RouteSource = "override" | "dimensions" | "hold" | "classifier" | "default" | "load" | "cooldown" | "pin";
|
|
9
|
+
/** `stateless`: the request carried no session key, so the planner saw a new session on every call. */
|
|
10
|
+
export type RelayRouteEvent = { route: "hub/auto"; tier: string; source: RouteSource; score: number; ms: number; stateless?: boolean } & Partial<SwitchTrace>;
|
|
10
11
|
type EstimateInputTokens = (messages: RelayRequest["messages"], tools?: unknown[]) => number;
|
|
12
|
+
type AutoState = { stage: StageState; pin?: { tier: Tier; alias: string } };
|
|
13
|
+
/**
|
|
14
|
+
* A `hub/auto` decision (#199). `backend` is the stage backend; the relay only reorders it with its fallback, trying
|
|
15
|
+
* `prefer` (an enforced tool loop's pinned backend) first when that is the fallback, and reports a reorder through `moved`.
|
|
16
|
+
*/
|
|
17
|
+
export interface AutoChoice { backend: ModelBackend; route: RelayRouteEvent; movable: boolean; prefer?: string; moved: (alias: string, source: "load" | "cooldown" | "pin") => void }
|
|
11
18
|
|
|
12
19
|
/** Stage selection for the relay's virtual `hub/auto` model. */
|
|
13
20
|
export class AutoRouteSelector {
|
|
14
|
-
private readonly states = new SessionState<
|
|
21
|
+
private readonly states = new SessionState<AutoState>(512, 60 * 60_000);
|
|
15
22
|
|
|
16
23
|
constructor(
|
|
17
24
|
private readonly options: RelaySelectorOptions,
|
|
@@ -20,33 +27,38 @@ export class AutoRouteSelector {
|
|
|
20
27
|
private readonly estimateInputTokens: EstimateInputTokens,
|
|
21
28
|
) {}
|
|
22
29
|
|
|
23
|
-
async select(body: RelayRequest): Promise<
|
|
30
|
+
async select(body: RelayRequest): Promise<AutoChoice> {
|
|
24
31
|
const started = performance.now();
|
|
25
32
|
let sessionKey: string | undefined;
|
|
26
33
|
try {
|
|
27
34
|
const key = this.options.routeSessionKey?.(body);
|
|
28
35
|
if (typeof key === "string" && key.length > 0 && key.length <= 512) sessionKey = key;
|
|
29
36
|
} catch { /* host identity lookup is fail-open */ }
|
|
30
|
-
const
|
|
37
|
+
const prior = sessionKey ? this.states.get(sessionKey) : undefined;
|
|
31
38
|
|
|
32
39
|
let backend = this.defaultBackend;
|
|
33
|
-
let tier = this.aliasOf(backend);
|
|
34
40
|
let source: RouteSource = "default";
|
|
35
41
|
let score = 0;
|
|
42
|
+
let trace: SwitchTrace | undefined;
|
|
43
|
+
let next: AutoState | undefined;
|
|
44
|
+
let movable = true;
|
|
45
|
+
let prefer: string | undefined;
|
|
36
46
|
try {
|
|
37
|
-
const
|
|
38
|
-
const decision = selectStage(
|
|
39
|
-
|
|
47
|
+
const conversation = normalizeConversation(body);
|
|
48
|
+
const decision = selectStage(extractToolSignals(conversation), { mode: "efficient_first", confidenceThreshold: 0.5, capableHoldTurns: 2 }, prior?.stage ?? { capableHoldTurnsRemaining: 0 });
|
|
49
|
+
const inputTokens = this.estimateInputTokens(body.messages, body.tools);
|
|
50
|
+
const fits = (candidate: Tier) => { const staged = this.stageBackend(candidate, body, inputTokens); return !!staged && (staged.kind === "mlx" || inputTokens <= this.options.dgxMaxInputTokens); };
|
|
51
|
+
const staged = stayOrSwitch(this.options.staySwitch?.(), prior?.pin?.tier, decision, turnKind(conversation), { inputTokens, fits });
|
|
52
|
+
next = { stage: decision.state, ...(staged.pin ? { pin: { tier: staged.pin, alias: "" } } : {}) };
|
|
53
|
+
trace = staged.trace;
|
|
40
54
|
score = decision.score;
|
|
41
55
|
source = this.sourceOf(decision.source);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
source = "default";
|
|
49
|
-
}
|
|
56
|
+
// Enforced, a tool loop that keeps its tier keeps its backend too: a move inside the tier costs the same prefill (#199).
|
|
57
|
+
movable = !(trace.staySwitch === "enforce" && trace.turnType === "tool_result" && trace.plan === "stay" && trace.reason !== "new_pin");
|
|
58
|
+
if (!movable) prefer = prior?.pin?.alias;
|
|
59
|
+
const stagedBackend = this.stageBackend(staged.tier, body, inputTokens);
|
|
60
|
+
if (stagedBackend) backend = stagedBackend;
|
|
61
|
+
else source = "default";
|
|
50
62
|
} catch {
|
|
51
63
|
source = "default";
|
|
52
64
|
}
|
|
@@ -56,27 +68,31 @@ export class AutoRouteSelector {
|
|
|
56
68
|
const override = await this.options.selectBackend(body);
|
|
57
69
|
if (this.isAllowed(override)) {
|
|
58
70
|
backend = override;
|
|
59
|
-
tier = this.aliasOf(override);
|
|
60
71
|
source = "override";
|
|
61
72
|
}
|
|
62
73
|
} catch { /* deterministic stage choice is the fail-open route */ }
|
|
63
74
|
}
|
|
64
75
|
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
if (next?.pin) next.pin.alias = this.aliasOf(backend);
|
|
77
|
+
if (sessionKey && next) this.states.set(sessionKey, next);
|
|
78
|
+
const route: RelayRouteEvent = { route: "hub/auto", tier: this.aliasOf(backend), source, score, ms: Math.max(0, performance.now() - started), ...trace, ...(sessionKey ? {} : { stateless: true }) };
|
|
79
|
+
return { backend, route, movable, ...(prefer ? { prefer } : {}), moved: (alias, moveSource) => {
|
|
80
|
+
route.tier = alias;
|
|
81
|
+
route.source = moveSource;
|
|
82
|
+
if (next?.pin) next.pin.alias = alias;
|
|
83
|
+
} };
|
|
67
84
|
}
|
|
68
85
|
|
|
69
|
-
private stageBackend(tier: "capable" | "efficient", body: RelayRequest): ModelBackend | undefined {
|
|
86
|
+
private stageBackend(tier: "capable" | "efficient", body: RelayRequest, input: number): ModelBackend | undefined {
|
|
70
87
|
if (tier === "capable") return "dgx/coding" in this.options.allowedDGXmodels ? { kind: "dgx", alias: "dgx/coding" } : undefined;
|
|
71
|
-
if (this.options.mlx && this.mlxFitsBudget(body)) return { kind: "mlx", alias: this.mlxAlias };
|
|
88
|
+
if (this.options.mlx && this.mlxFitsBudget(body, input)) return { kind: "mlx", alias: this.mlxAlias };
|
|
72
89
|
return "dgx/fast" in this.options.allowedDGXmodels ? { kind: "dgx", alias: "dgx/fast" } : undefined;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
private mlxFitsBudget(body: RelayRequest): boolean {
|
|
92
|
+
private mlxFitsBudget(body: RelayRequest, input: number): boolean {
|
|
76
93
|
const context = Math.min(8192, this.options.mlx?.contextWindow ?? 8192);
|
|
77
94
|
const maxOutput = this.options.mlx?.maxTokens ?? 2048;
|
|
78
95
|
const output = body.max_tokens ?? maxOutput;
|
|
79
|
-
const input = this.estimateInputTokens(body.messages, body.tools);
|
|
80
96
|
const inputLimit = this.options.mlx?.maxInputTokens ?? (this.options.mlx?.provider === "ollama" ? 6000 : 16_000);
|
|
81
97
|
return Number.isInteger(output) && output > 0 && output <= maxOutput && input <= inputLimit && input + output <= context;
|
|
82
98
|
}
|
|
@@ -95,8 +111,4 @@ export class AutoRouteSelector {
|
|
|
95
111
|
if (source === "llm-classifier") return "classifier";
|
|
96
112
|
return "default";
|
|
97
113
|
}
|
|
98
|
-
|
|
99
|
-
private emit(event: RelayRouteEvent): void {
|
|
100
|
-
try { this.options.onRoute?.(event); } catch { /* observation cannot fail routing */ }
|
|
101
|
-
}
|
|
102
114
|
}
|