harness-dispatch 0.4.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/LICENSE +21 -0
- package/README.md +582 -0
- package/config.default.yaml +397 -0
- package/data/coding_benchmarks.json +431 -0
- package/dist/auth.d.ts +9 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +73 -0
- package/dist/auth.js.map +1 -0
- package/dist/billing.d.ts +7 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +201 -0
- package/dist/billing.js.map +1 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +488 -0
- package/dist/bin.js.map +1 -0
- package/dist/breaker-store.d.ts +31 -0
- package/dist/breaker-store.d.ts.map +1 -0
- package/dist/breaker-store.js +103 -0
- package/dist/breaker-store.js.map +1 -0
- package/dist/circuit-breaker.d.ts +47 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +102 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +1248 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard/live.d.ts +49 -0
- package/dist/dashboard/live.d.ts.map +1 -0
- package/dist/dashboard/live.js +149 -0
- package/dist/dashboard/live.js.map +1 -0
- package/dist/dispatch-log.d.ts +46 -0
- package/dist/dispatch-log.d.ts.map +1 -0
- package/dist/dispatch-log.js +89 -0
- package/dist/dispatch-log.js.map +1 -0
- package/dist/dispatchers/base.d.ts +55 -0
- package/dist/dispatchers/base.d.ts.map +1 -0
- package/dist/dispatchers/base.js +80 -0
- package/dist/dispatchers/base.js.map +1 -0
- package/dist/dispatchers/generic-cli.d.ts +30 -0
- package/dist/dispatchers/generic-cli.d.ts.map +1 -0
- package/dist/dispatchers/generic-cli.js +471 -0
- package/dist/dispatchers/generic-cli.js.map +1 -0
- package/dist/dispatchers/openai-compatible.d.ts +57 -0
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -0
- package/dist/dispatchers/openai-compatible.js +662 -0
- package/dist/dispatchers/openai-compatible.js.map +1 -0
- package/dist/dispatchers/shared/kill-tree.d.ts +21 -0
- package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -0
- package/dist/dispatchers/shared/kill-tree.js +35 -0
- package/dist/dispatchers/shared/kill-tree.js.map +1 -0
- package/dist/dispatchers/shared/rate-limit-headers.d.ts +22 -0
- package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -0
- package/dist/dispatchers/shared/rate-limit-headers.js +124 -0
- package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts +53 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.js +274 -0
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -0
- package/dist/dispatchers/shared/subprocess.d.ts +25 -0
- package/dist/dispatchers/shared/subprocess.d.ts.map +1 -0
- package/dist/dispatchers/shared/subprocess.js +110 -0
- package/dist/dispatchers/shared/subprocess.js.map +1 -0
- package/dist/dispatchers/shared/which-available.d.ts +2 -0
- package/dist/dispatchers/shared/which-available.d.ts.map +1 -0
- package/dist/dispatchers/shared/which-available.js +8 -0
- package/dist/dispatchers/shared/which-available.js.map +1 -0
- package/dist/dispatchers/shared/windows-cmd.d.ts +38 -0
- package/dist/dispatchers/shared/windows-cmd.d.ts.map +1 -0
- package/dist/dispatchers/shared/windows-cmd.js +91 -0
- package/dist/dispatchers/shared/windows-cmd.js.map +1 -0
- package/dist/http/server.d.ts +15 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +495 -0
- package/dist/http/server.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/job-runner.d.ts +19 -0
- package/dist/job-runner.d.ts.map +1 -0
- package/dist/job-runner.js +42 -0
- package/dist/job-runner.js.map +1 -0
- package/dist/jobs.d.ts +116 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +542 -0
- package/dist/jobs.js.map +1 -0
- package/dist/leaderboard.d.ts +62 -0
- package/dist/leaderboard.d.ts.map +1 -0
- package/dist/leaderboard.js +287 -0
- package/dist/leaderboard.js.map +1 -0
- package/dist/mcp/config-hot-reload.d.ts +64 -0
- package/dist/mcp/config-hot-reload.d.ts.map +1 -0
- package/dist/mcp/config-hot-reload.js +144 -0
- package/dist/mcp/config-hot-reload.js.map +1 -0
- package/dist/mcp/dispatcher-factory.d.ts +20 -0
- package/dist/mcp/dispatcher-factory.d.ts.map +1 -0
- package/dist/mcp/dispatcher-factory.js +30 -0
- package/dist/mcp/dispatcher-factory.js.map +1 -0
- package/dist/mcp/resources.d.ts +8 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +45 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +36 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +80 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +171 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +658 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/observability/index.d.ts +52 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +111 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/spans.d.ts +47 -0
- package/dist/observability/spans.d.ts.map +1 -0
- package/dist/observability/spans.js +72 -0
- package/dist/observability/spans.js.map +1 -0
- package/dist/quota.d.ts +91 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +331 -0
- package/dist/quota.js.map +1 -0
- package/dist/route-policy.d.ts +24 -0
- package/dist/route-policy.d.ts.map +1 -0
- package/dist/route-policy.js +92 -0
- package/dist/route-policy.js.map +1 -0
- package/dist/router.d.ts +162 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +878 -0
- package/dist/router.js.map +1 -0
- package/dist/safety.d.ts +7 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +33 -0
- package/dist/safety.js.map +1 -0
- package/dist/status.d.ts +100 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +228 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +402 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +13 -0
- package/dist/version.js.map +1 -0
- package/dist/working-dir.d.ts +15 -0
- package/dist/working-dir.d.ts.map +1 -0
- package/dist/working-dir.js +22 -0
- package/dist/working-dir.js.map +1 -0
- package/dist/workspaces.d.ts +19 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +352 -0
- package/dist/workspaces.js.map +1 -0
- package/package.json +96 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-balancing router for harness-dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Routing strategy
|
|
5
|
+
* ----------------
|
|
6
|
+
* Services are grouped by tier (lower number = higher quality). Selection
|
|
7
|
+
* prefers the best-scoring candidate in the lowest eligible tier:
|
|
8
|
+
*
|
|
9
|
+
* Tier 1 (frontier) -> Tier 2 (strong) -> Tier 3 (fast/local)
|
|
10
|
+
*
|
|
11
|
+
* Services that are circuit-broken, policy-blocked, unavailable, or already
|
|
12
|
+
* tried this request are excluded from candidacy. On a failed dispatch
|
|
13
|
+
* (including rate limits) the router excludes that service and retries with
|
|
14
|
+
* the next-best candidate, up to `maxFallbacks` extra attempts per request
|
|
15
|
+
* (default 2, i.e. 3 attempts total — pass maxFallbacks: 0 to let the caller
|
|
16
|
+
* own retries). A request can therefore fail with untried routes remaining
|
|
17
|
+
* when the attempt cap is hit before candidates run out.
|
|
18
|
+
*
|
|
19
|
+
* Quality scoring
|
|
20
|
+
* ---------------
|
|
21
|
+
* Within a tier, services are ranked by a composite score:
|
|
22
|
+
*
|
|
23
|
+
* final_score = quality_score * cli_capability * capability[task_type]
|
|
24
|
+
* * quota_score * weight
|
|
25
|
+
*
|
|
26
|
+
* Adjustments applied during selection (reflected in the reported
|
|
27
|
+
* finalScore for picked/fallback routes, but not for forced/explicit ones):
|
|
28
|
+
* - Cost-based penalty under the "standard" route policy
|
|
29
|
+
* (nonLocalIncludedRoutePenalty): 0 for local routes, -0.2 for non-local
|
|
30
|
+
* included-plan/free-quota routes, -0.4 for routes that can incur real
|
|
31
|
+
* per-use cost (metered API, unknown billing) — cheapest/lowest-risk
|
|
32
|
+
* wins ties, in that order.
|
|
33
|
+
* - +0.5 when hints.model matches the service name or one of its models.
|
|
34
|
+
* - Under prefer_large_context, +0.3 for routes declaring >=2M
|
|
35
|
+
* max_input_tokens and +0.15 for >=1M — declared context size, not
|
|
36
|
+
* harness name.
|
|
37
|
+
* - +0.3 when task_type="local" and the service is an openai_compatible
|
|
38
|
+
* endpoint on localhost / 127.0.0.1.
|
|
39
|
+
*
|
|
40
|
+
* Tier auto-derivation
|
|
41
|
+
* --------------------
|
|
42
|
+
* If a service has `leaderboardModel` set in config, its tier is
|
|
43
|
+
* auto-derived from the Arena ELO score via LeaderboardCache.autoTier().
|
|
44
|
+
* Explicit `tier` in config is the fallback when ELO is unavailable.
|
|
45
|
+
*
|
|
46
|
+
* R3: adds `stream()` / `streamTo()` that emit `DispatcherEvent`s with an
|
|
47
|
+
* attached `RoutingDecision`. The buffered `route` / `routeTo` methods are
|
|
48
|
+
* reimplemented on top of the streaming primitives.
|
|
49
|
+
*/
|
|
50
|
+
import path from "node:path";
|
|
51
|
+
import { CircuitBreaker } from "./circuit-breaker.js";
|
|
52
|
+
import { BreakerStore } from "./breaker-store.js";
|
|
53
|
+
import { drainDispatcherStream } from "./dispatchers/base.js";
|
|
54
|
+
import { withDispatcherSpan, withRouterSpan } from "./observability/spans.js";
|
|
55
|
+
import { buildRouteBilling } from "./billing.js";
|
|
56
|
+
import { logDispatch } from "./dispatch-log.js";
|
|
57
|
+
import { effectiveSafetyProfile, requestedSafetyProfile } from "./safety.js";
|
|
58
|
+
import { evaluateRoutePolicy, nonLocalIncludedRoutePenalty } from "./route-policy.js";
|
|
59
|
+
import { prepareWorkspace, workspacePolicyFor, } from "./workspaces.js";
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
const TASK_TYPES_WITH_CAPABILITY = new Set([
|
|
64
|
+
"execute",
|
|
65
|
+
"plan",
|
|
66
|
+
"review",
|
|
67
|
+
]);
|
|
68
|
+
const workspaceLocks = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* `service` is a raw string from the caller — a near-miss ("codex" for
|
|
71
|
+
* "codex_cli") used to fail with no hint at what WOULD have worked, costing
|
|
72
|
+
* a round-trip to `usage` to find out.
|
|
73
|
+
*/
|
|
74
|
+
function unknownServiceError(service, valid) {
|
|
75
|
+
return `Unknown service: ${service} (valid route ids: ${valid.join(", ")})`;
|
|
76
|
+
}
|
|
77
|
+
function resolveModel(svc, taskType) {
|
|
78
|
+
if (svc.escalateModel && svc.escalateOn.includes(taskType)) {
|
|
79
|
+
return svc.escalateModel;
|
|
80
|
+
}
|
|
81
|
+
return svc.model;
|
|
82
|
+
}
|
|
83
|
+
function sameModel(a, b) {
|
|
84
|
+
if (!a || !b)
|
|
85
|
+
return false;
|
|
86
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
function modelMatchesService(name, svc, model) {
|
|
89
|
+
if (!model)
|
|
90
|
+
return false;
|
|
91
|
+
return (sameModel(name, model) ||
|
|
92
|
+
sameModel(svc.model, model) ||
|
|
93
|
+
sameModel(svc.leaderboardModel, model) ||
|
|
94
|
+
sameModel(svc.escalateModel, model));
|
|
95
|
+
}
|
|
96
|
+
function capabilityScore(svc, taskType) {
|
|
97
|
+
if (!TASK_TYPES_WITH_CAPABILITY.has(taskType))
|
|
98
|
+
return 1.0;
|
|
99
|
+
const key = taskType;
|
|
100
|
+
return svc.capabilities[key] ?? 1.0;
|
|
101
|
+
}
|
|
102
|
+
function workspaceLockKey(workingDir) {
|
|
103
|
+
const resolved = path.resolve(workingDir || process.cwd());
|
|
104
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
105
|
+
}
|
|
106
|
+
async function acquireWorkspaceLock(workingDir) {
|
|
107
|
+
const key = workspaceLockKey(workingDir);
|
|
108
|
+
const previous = workspaceLocks.get(key) ?? Promise.resolve();
|
|
109
|
+
let release;
|
|
110
|
+
const current = previous.catch(() => undefined).then(() => new Promise((resolve) => {
|
|
111
|
+
release = resolve;
|
|
112
|
+
}));
|
|
113
|
+
workspaceLocks.set(key, current);
|
|
114
|
+
await previous.catch(() => undefined);
|
|
115
|
+
return () => {
|
|
116
|
+
release();
|
|
117
|
+
if (workspaceLocks.get(key) === current) {
|
|
118
|
+
workspaceLocks.delete(key);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function withWorkspacePolicy(svc, serviceName, safetyProfile, requestedPolicy, workingDir, files, fn) {
|
|
123
|
+
const policy = workspacePolicyFor(svc, safetyProfile, requestedPolicy);
|
|
124
|
+
if (policy === "shared_locked") {
|
|
125
|
+
const release = await acquireWorkspaceLock(workingDir);
|
|
126
|
+
try {
|
|
127
|
+
const workspace = await prepareWorkspace({
|
|
128
|
+
routeName: serviceName,
|
|
129
|
+
policy,
|
|
130
|
+
workingDir,
|
|
131
|
+
files,
|
|
132
|
+
});
|
|
133
|
+
const result = await fn(workspace.effectiveWorkingDir, workspace.files);
|
|
134
|
+
return await workspace.finish(result);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
release();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const shouldLockSnapshot = safetyProfile !== "read_only" && (policy === "copy" || policy === "git_worktree");
|
|
141
|
+
const release = shouldLockSnapshot ? await acquireWorkspaceLock(workingDir) : undefined;
|
|
142
|
+
let workspace;
|
|
143
|
+
try {
|
|
144
|
+
workspace = await prepareWorkspace({
|
|
145
|
+
routeName: serviceName,
|
|
146
|
+
policy,
|
|
147
|
+
workingDir,
|
|
148
|
+
files,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
release?.();
|
|
153
|
+
}
|
|
154
|
+
const result = await fn(workspace.effectiveWorkingDir, workspace.files);
|
|
155
|
+
return await workspace.finish(result);
|
|
156
|
+
}
|
|
157
|
+
async function* streamWithWorkspacePolicy(svc, serviceName, safetyProfile, requestedPolicy, workingDir, files, makeStream) {
|
|
158
|
+
const policy = workspacePolicyFor(svc, safetyProfile, requestedPolicy);
|
|
159
|
+
if (policy === "shared_locked") {
|
|
160
|
+
const release = await acquireWorkspaceLock(workingDir);
|
|
161
|
+
try {
|
|
162
|
+
const workspace = await prepareWorkspace({
|
|
163
|
+
routeName: serviceName,
|
|
164
|
+
policy,
|
|
165
|
+
workingDir,
|
|
166
|
+
files,
|
|
167
|
+
});
|
|
168
|
+
for await (const event of makeStream(workspace.effectiveWorkingDir, workspace.files)) {
|
|
169
|
+
if (typeof event === "object" &&
|
|
170
|
+
event !== null &&
|
|
171
|
+
"type" in event &&
|
|
172
|
+
event.type === "completion") {
|
|
173
|
+
const completion = event;
|
|
174
|
+
if (completion.type === "completion") {
|
|
175
|
+
yield {
|
|
176
|
+
...completion,
|
|
177
|
+
result: await workspace.finish(completion.result),
|
|
178
|
+
};
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
yield event;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
release();
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const shouldLockSnapshot = safetyProfile !== "read_only" && (policy === "copy" || policy === "git_worktree");
|
|
191
|
+
const release = shouldLockSnapshot ? await acquireWorkspaceLock(workingDir) : undefined;
|
|
192
|
+
let workspace;
|
|
193
|
+
try {
|
|
194
|
+
workspace = await prepareWorkspace({
|
|
195
|
+
routeName: serviceName,
|
|
196
|
+
policy,
|
|
197
|
+
workingDir,
|
|
198
|
+
files,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
release?.();
|
|
203
|
+
}
|
|
204
|
+
for await (const event of makeStream(workspace.effectiveWorkingDir, workspace.files)) {
|
|
205
|
+
if (typeof event === "object" &&
|
|
206
|
+
event !== null &&
|
|
207
|
+
"type" in event &&
|
|
208
|
+
event.type === "completion") {
|
|
209
|
+
const completion = event;
|
|
210
|
+
if (completion.type === "completion") {
|
|
211
|
+
yield {
|
|
212
|
+
...completion,
|
|
213
|
+
result: await workspace.finish(completion.result),
|
|
214
|
+
};
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
yield event;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Router
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
export class Router {
|
|
225
|
+
config;
|
|
226
|
+
quota;
|
|
227
|
+
dispatchers;
|
|
228
|
+
leaderboard;
|
|
229
|
+
breakers = new Map();
|
|
230
|
+
lastSkippedRoutes = [];
|
|
231
|
+
breakerStore;
|
|
232
|
+
constructor(config, quota, dispatchers, leaderboard, breakerStore) {
|
|
233
|
+
this.config = config;
|
|
234
|
+
this.quota = quota;
|
|
235
|
+
this.dispatchers = dispatchers;
|
|
236
|
+
this.leaderboard = leaderboard;
|
|
237
|
+
this.breakerStore = breakerStore ?? new BreakerStore();
|
|
238
|
+
// Restart survival: a route rate-limited right before the process died
|
|
239
|
+
// would otherwise come back with a clean slate the instant the server
|
|
240
|
+
// restarts — hydrate any cooldown still in effect from the last process.
|
|
241
|
+
const persisted = this.breakerStore.loadAll();
|
|
242
|
+
for (const name of Object.keys(config.services)) {
|
|
243
|
+
const breaker = new CircuitBreaker();
|
|
244
|
+
const snapshot = persisted[name];
|
|
245
|
+
if (snapshot)
|
|
246
|
+
breaker.restore(snapshot);
|
|
247
|
+
this.breakers.set(name, breaker);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
persistBreaker(service) {
|
|
251
|
+
const breaker = this.breakers.get(service);
|
|
252
|
+
if (breaker)
|
|
253
|
+
this.breakerStore.save(service, breaker.snapshot());
|
|
254
|
+
}
|
|
255
|
+
getBreaker(service) {
|
|
256
|
+
return this.breakers.get(service);
|
|
257
|
+
}
|
|
258
|
+
skippedRoutes() {
|
|
259
|
+
return this.lastSkippedRoutes.slice();
|
|
260
|
+
}
|
|
261
|
+
async pickService(opts = {}) {
|
|
262
|
+
const hints = opts.hints ?? {};
|
|
263
|
+
const exclude = opts.exclude ?? new Set();
|
|
264
|
+
const forceService = hints.service;
|
|
265
|
+
const preferredModel = hints.model;
|
|
266
|
+
const preferLargeContext = hints.preferLargeContext ?? false;
|
|
267
|
+
const taskType = hints.taskType ?? "";
|
|
268
|
+
const filterHarness = hints.harness;
|
|
269
|
+
const requestedSafety = hints.safetyProfile;
|
|
270
|
+
const requestedWorkspacePolicy = hints.workspacePolicy;
|
|
271
|
+
const skippedRoutes = [];
|
|
272
|
+
this.lastSkippedRoutes = skippedRoutes;
|
|
273
|
+
if (forceService) {
|
|
274
|
+
if (exclude.has(forceService))
|
|
275
|
+
return null;
|
|
276
|
+
const breaker = this.breakers.get(forceService);
|
|
277
|
+
const dispatcher = this.dispatchers[forceService];
|
|
278
|
+
const svc = this.config.services[forceService];
|
|
279
|
+
if (!svc)
|
|
280
|
+
return null;
|
|
281
|
+
const policy = evaluateRoutePolicy(forceService, svc, {
|
|
282
|
+
...(dispatcher !== undefined ? { dispatcher } : {}),
|
|
283
|
+
circuitBroken: Boolean(breaker?.isTripped),
|
|
284
|
+
...(requestedSafety !== undefined ? { requestedSafetyProfile: requestedSafety } : {}),
|
|
285
|
+
...(hints.routePolicy !== undefined ? { routePolicy: hints.routePolicy } : {}),
|
|
286
|
+
});
|
|
287
|
+
if (policy.skipped)
|
|
288
|
+
skippedRoutes.push(policy.skipped);
|
|
289
|
+
if (policy.blocked || dispatcher === undefined)
|
|
290
|
+
return null;
|
|
291
|
+
const quotaScore = await this.quota.getQuotaScore(forceService);
|
|
292
|
+
const { qualityScore, elo } = await this.leaderboard.getQualityScore(svc.leaderboardModel, svc.thinkingLevel);
|
|
293
|
+
const capScore = capabilityScore(svc, taskType);
|
|
294
|
+
const finalScore = qualityScore * svc.cliCapability * capScore * quotaScore * svc.weight;
|
|
295
|
+
const effectiveSafety = effectiveSafetyProfile(svc, requestedSafety);
|
|
296
|
+
return {
|
|
297
|
+
service: forceService,
|
|
298
|
+
tier: svc.tier,
|
|
299
|
+
quotaScore,
|
|
300
|
+
qualityScore,
|
|
301
|
+
cliCapability: svc.cliCapability,
|
|
302
|
+
capabilityScore: capScore,
|
|
303
|
+
taskType,
|
|
304
|
+
// Always pass through a requested model, even if it doesn't match
|
|
305
|
+
// anything this route statically declares — modelMatchesService only
|
|
306
|
+
// affects scoring (which route gets picked), not whether the
|
|
307
|
+
// caller's request reaches the dispatcher. A route the router
|
|
308
|
+
// "doesn't recognize" a model for may still support it (CLIs accept
|
|
309
|
+
// arbitrary --model values); silently discarding the request instead
|
|
310
|
+
// meant a mismatched hints.model got no error and no explanation.
|
|
311
|
+
model: preferredModel ?? resolveModel(svc, taskType),
|
|
312
|
+
...(preferredModel !== undefined
|
|
313
|
+
? { modelHintMatched: modelMatchesService(forceService, svc, preferredModel) }
|
|
314
|
+
: {}),
|
|
315
|
+
elo: elo ?? undefined,
|
|
316
|
+
finalScore,
|
|
317
|
+
reason: "forced",
|
|
318
|
+
skippedRoutes: skippedRoutes.slice(),
|
|
319
|
+
safetyProfile: requestedSafetyProfile(svc, requestedSafety),
|
|
320
|
+
effectiveSafetyProfile: effectiveSafety,
|
|
321
|
+
billing: buildRouteBilling(svc),
|
|
322
|
+
workspacePolicy: workspacePolicyFor(svc, effectiveSafety, requestedWorkspacePolicy),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const tierCandidates = new Map();
|
|
326
|
+
for (const [name, svc] of Object.entries(this.config.services)) {
|
|
327
|
+
if (exclude.has(name))
|
|
328
|
+
continue;
|
|
329
|
+
const breaker = this.breakers.get(name);
|
|
330
|
+
const dispatcher = this.dispatchers[name];
|
|
331
|
+
const policy = evaluateRoutePolicy(name, svc, {
|
|
332
|
+
...(dispatcher !== undefined ? { dispatcher } : {}),
|
|
333
|
+
circuitBroken: Boolean(breaker?.isTripped),
|
|
334
|
+
...(requestedSafety !== undefined ? { requestedSafetyProfile: requestedSafety } : {}),
|
|
335
|
+
...(hints.routePolicy !== undefined ? { routePolicy: hints.routePolicy } : {}),
|
|
336
|
+
});
|
|
337
|
+
if (policy.skipped)
|
|
338
|
+
skippedRoutes.push(policy.skipped);
|
|
339
|
+
if (policy.blocked || dispatcher === undefined)
|
|
340
|
+
continue;
|
|
341
|
+
const harnessKey = svc.harness ?? name;
|
|
342
|
+
if (filterHarness && harnessKey !== filterHarness)
|
|
343
|
+
continue;
|
|
344
|
+
const tier = svc.leaderboardModel
|
|
345
|
+
? await this.leaderboard.autoTier(svc.leaderboardModel, svc.thinkingLevel, svc.tier)
|
|
346
|
+
: svc.tier;
|
|
347
|
+
const quotaScore = await this.quota.getQuotaScore(name);
|
|
348
|
+
const { qualityScore, elo } = await this.leaderboard.getQualityScore(svc.leaderboardModel, svc.thinkingLevel);
|
|
349
|
+
const capScore = capabilityScore(svc, taskType);
|
|
350
|
+
const effectiveQuality = qualityScore * svc.cliCapability * capScore;
|
|
351
|
+
let score = effectiveQuality * quotaScore * svc.weight;
|
|
352
|
+
if ((hints.routePolicy ?? "standard") === "standard") {
|
|
353
|
+
score -= nonLocalIncludedRoutePenalty(buildRouteBilling(svc));
|
|
354
|
+
}
|
|
355
|
+
if (modelMatchesService(name, svc, preferredModel)) {
|
|
356
|
+
score += 0.5;
|
|
357
|
+
}
|
|
358
|
+
if (preferLargeContext) {
|
|
359
|
+
// Boost by DECLARED context size (max_input_tokens in the route's
|
|
360
|
+
// config), not by harness name — a 2M-context route (e.g.
|
|
361
|
+
// Antigravity's default) gets the full boost, 1M-context routes get
|
|
362
|
+
// half, and any user-added large-context harness benefits equally.
|
|
363
|
+
const maxIn = svc.maxInputTokens ?? 0;
|
|
364
|
+
if (maxIn >= 2_000_000)
|
|
365
|
+
score += 0.3;
|
|
366
|
+
else if (maxIn >= 1_000_000)
|
|
367
|
+
score += 0.15;
|
|
368
|
+
}
|
|
369
|
+
if (taskType === "local" &&
|
|
370
|
+
svc.type === "openai_compatible" &&
|
|
371
|
+
(svc.baseUrl?.includes("localhost") || svc.baseUrl?.includes("127.0.0.1"))) {
|
|
372
|
+
score += 0.3;
|
|
373
|
+
}
|
|
374
|
+
const bucket = tierCandidates.get(tier);
|
|
375
|
+
const candidate = {
|
|
376
|
+
score,
|
|
377
|
+
name,
|
|
378
|
+
quotaScore,
|
|
379
|
+
qualityScore,
|
|
380
|
+
elo,
|
|
381
|
+
cliCapability: svc.cliCapability,
|
|
382
|
+
capScore,
|
|
383
|
+
};
|
|
384
|
+
if (bucket)
|
|
385
|
+
bucket.push(candidate);
|
|
386
|
+
else
|
|
387
|
+
tierCandidates.set(tier, [candidate]);
|
|
388
|
+
}
|
|
389
|
+
if (tierCandidates.size === 0)
|
|
390
|
+
return null;
|
|
391
|
+
let minConfiguredTier = Infinity;
|
|
392
|
+
for (const svc of Object.values(this.config.services)) {
|
|
393
|
+
if (svc.enabled && svc.tier < minConfiguredTier)
|
|
394
|
+
minConfiguredTier = svc.tier;
|
|
395
|
+
}
|
|
396
|
+
const sortedTiers = [...tierCandidates.keys()].sort((a, b) => a - b);
|
|
397
|
+
for (const tier of sortedTiers) {
|
|
398
|
+
const candidates = tierCandidates.get(tier);
|
|
399
|
+
if (!candidates || candidates.length === 0)
|
|
400
|
+
continue;
|
|
401
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
402
|
+
const best = candidates[0];
|
|
403
|
+
const svc = this.config.services[best.name];
|
|
404
|
+
const reason = tier > minConfiguredTier
|
|
405
|
+
? `tier ${tier} fallback (all tier ${minConfiguredTier} services exhausted)`
|
|
406
|
+
: `tier ${tier} best (${candidates.length} available)`;
|
|
407
|
+
const effectiveSafety = effectiveSafetyProfile(svc, requestedSafety);
|
|
408
|
+
return {
|
|
409
|
+
service: best.name,
|
|
410
|
+
tier,
|
|
411
|
+
quotaScore: best.quotaScore,
|
|
412
|
+
qualityScore: best.qualityScore,
|
|
413
|
+
cliCapability: best.cliCapability,
|
|
414
|
+
capabilityScore: best.capScore,
|
|
415
|
+
taskType,
|
|
416
|
+
// See the forced-service branch above for why this always prefers
|
|
417
|
+
// the requested model rather than gating on modelMatchesService.
|
|
418
|
+
model: preferredModel ?? resolveModel(svc, taskType),
|
|
419
|
+
...(preferredModel !== undefined
|
|
420
|
+
? { modelHintMatched: modelMatchesService(best.name, svc, preferredModel) }
|
|
421
|
+
: {}),
|
|
422
|
+
elo: best.elo ?? undefined,
|
|
423
|
+
finalScore: best.score,
|
|
424
|
+
reason,
|
|
425
|
+
skippedRoutes: skippedRoutes.slice(),
|
|
426
|
+
safetyProfile: requestedSafetyProfile(svc, requestedSafety),
|
|
427
|
+
effectiveSafetyProfile: effectiveSafety,
|
|
428
|
+
billing: buildRouteBilling(svc),
|
|
429
|
+
workspacePolicy: workspacePolicyFor(svc, effectiveSafety, requestedWorkspacePolicy),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Stream events from the chosen dispatcher, with the same fallback logic
|
|
436
|
+
* as `route()`. When a dispatch fails (non-rate-limit), the router picks
|
|
437
|
+
* another service and yields that service's events — so the caller sees
|
|
438
|
+
* events from potentially multiple services during fallback.
|
|
439
|
+
*
|
|
440
|
+
* The last `completion` or `error` event always reflects the final
|
|
441
|
+
* outcome (success-with-fallback or all-attempts-failed).
|
|
442
|
+
*/
|
|
443
|
+
stream(prompt, files, workingDir, opts = {}) {
|
|
444
|
+
return this.#runStream(prompt, files, workingDir, opts);
|
|
445
|
+
}
|
|
446
|
+
async *#runStream(prompt, files, workingDir, opts) {
|
|
447
|
+
const hints = opts.hints ?? {};
|
|
448
|
+
const maxFallbacks = opts.maxFallbacks ?? 2;
|
|
449
|
+
const tried = new Set();
|
|
450
|
+
let lastDecision = null;
|
|
451
|
+
// `defaultTimeoutMs` (currently only `job`'s background ceiling) is a
|
|
452
|
+
// budget for the WHOLE call, not a per-attempt allowance — without this,
|
|
453
|
+
// 3 fallback attempts (default + 2 retries) each getting the full
|
|
454
|
+
// default would let one `job` call run 3x its stated ceiling before
|
|
455
|
+
// failing conclusively. An explicit `hints.timeoutMs` or a route's own
|
|
456
|
+
// configured `timeoutMs` is a deliberate per-attempt choice and is NOT
|
|
457
|
+
// budgeted this way.
|
|
458
|
+
const callStart = Date.now();
|
|
459
|
+
for (let attempt = 0; attempt <= maxFallbacks; attempt++) {
|
|
460
|
+
const decision = await this.pickService({
|
|
461
|
+
hints,
|
|
462
|
+
prompt,
|
|
463
|
+
files,
|
|
464
|
+
exclude: tried,
|
|
465
|
+
});
|
|
466
|
+
if (decision === null) {
|
|
467
|
+
if (lastDecision === null) {
|
|
468
|
+
const breakerInfo = {};
|
|
469
|
+
for (const [name, b] of this.breakers)
|
|
470
|
+
breakerInfo[name] = b.status();
|
|
471
|
+
const result = {
|
|
472
|
+
output: "",
|
|
473
|
+
service: "none",
|
|
474
|
+
success: false,
|
|
475
|
+
error: "No available services — all are disabled, exhausted, or circuit-broken. " +
|
|
476
|
+
`Breaker state: ${JSON.stringify(breakerInfo)}`,
|
|
477
|
+
skippedRoutes: this.skippedRoutes(),
|
|
478
|
+
};
|
|
479
|
+
yield { event: { type: "completion", result }, decision: null };
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
lastDecision = decision;
|
|
484
|
+
if (attempt > 0) {
|
|
485
|
+
decision.reason += ` (fallback #${attempt} — prev failed)`;
|
|
486
|
+
}
|
|
487
|
+
const dispatcher = this.dispatchers[decision.service];
|
|
488
|
+
const svc = this.config.services[decision.service];
|
|
489
|
+
const dispatchOpts = {};
|
|
490
|
+
if (decision.model !== undefined)
|
|
491
|
+
dispatchOpts.modelOverride = decision.model;
|
|
492
|
+
if (decision.effectiveSafetyProfile !== undefined) {
|
|
493
|
+
dispatchOpts.safetyProfile = decision.effectiveSafetyProfile;
|
|
494
|
+
}
|
|
495
|
+
let effectiveTimeoutMs = hints.timeoutMs ?? svc.timeoutMs;
|
|
496
|
+
if (effectiveTimeoutMs === undefined && opts.defaultTimeoutMs !== undefined) {
|
|
497
|
+
const remaining = opts.defaultTimeoutMs - (Date.now() - callStart);
|
|
498
|
+
if (remaining <= 0 && attempt > 0) {
|
|
499
|
+
// Whole-call budget already spent on earlier attempts — the
|
|
500
|
+
// previous attempt's completion event was already yielded, so
|
|
501
|
+
// stop retrying instead of starting another full-length attempt.
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
effectiveTimeoutMs = Math.max(remaining, 1);
|
|
505
|
+
}
|
|
506
|
+
if (effectiveTimeoutMs !== undefined)
|
|
507
|
+
dispatchOpts.timeoutMs = effectiveTimeoutMs;
|
|
508
|
+
let finalResult = null;
|
|
509
|
+
for await (const event of streamWithWorkspacePolicy(svc, decision.service, decision.effectiveSafetyProfile, decision.workspacePolicy, workingDir, files, (effectiveWorkingDir, effectiveFiles) => dispatcher.stream(prompt, effectiveFiles, effectiveWorkingDir, dispatchOpts))) {
|
|
510
|
+
yield { event, decision };
|
|
511
|
+
if (event.type === "completion") {
|
|
512
|
+
finalResult = event.result;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (finalResult === null) {
|
|
516
|
+
// Dispatcher misbehaved — synthesize a failure and YIELD it so the
|
|
517
|
+
// caller always receives a terminal completion event for the attempt,
|
|
518
|
+
// then record it for breaker/quota accounting.
|
|
519
|
+
finalResult = {
|
|
520
|
+
output: "",
|
|
521
|
+
service: decision.service,
|
|
522
|
+
success: false,
|
|
523
|
+
error: "Dispatcher stream ended without a completion event",
|
|
524
|
+
};
|
|
525
|
+
yield { event: { type: "completion", result: finalResult }, decision };
|
|
526
|
+
}
|
|
527
|
+
this.handleResult(decision.service, finalResult, decision);
|
|
528
|
+
if (finalResult.success)
|
|
529
|
+
return;
|
|
530
|
+
// Rate-limited and transient failures alike: the breaker state was
|
|
531
|
+
// updated by handleResult; exclude this service and fall back to the
|
|
532
|
+
// next-best candidate rather than aborting the caller's request.
|
|
533
|
+
tried.add(decision.service);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Stream from a specific service, bypassing tier selection. Same semantics
|
|
538
|
+
* as `routeTo()` but yields events in real time.
|
|
539
|
+
*/
|
|
540
|
+
streamTo(service, prompt, files, workingDir, opts = {}) {
|
|
541
|
+
return this.#runStreamTo(service, prompt, files, workingDir, opts);
|
|
542
|
+
}
|
|
543
|
+
async *#runStreamTo(service, prompt, files, workingDir, opts) {
|
|
544
|
+
if (!(service in this.dispatchers)) {
|
|
545
|
+
yield {
|
|
546
|
+
event: {
|
|
547
|
+
type: "completion",
|
|
548
|
+
result: {
|
|
549
|
+
output: "",
|
|
550
|
+
service,
|
|
551
|
+
success: false,
|
|
552
|
+
error: unknownServiceError(service, Object.keys(this.dispatchers)),
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
decision: null,
|
|
556
|
+
};
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const breaker = this.breakers.get(service);
|
|
560
|
+
if (breaker && breaker.isTripped) {
|
|
561
|
+
const cd = Math.round(breaker.cooldownRemaining() * 10) / 10;
|
|
562
|
+
yield {
|
|
563
|
+
event: {
|
|
564
|
+
type: "completion",
|
|
565
|
+
result: {
|
|
566
|
+
output: "",
|
|
567
|
+
service,
|
|
568
|
+
success: false,
|
|
569
|
+
error: `'${service}' is circuit-broken — ${cd}s cooldown remaining`,
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
decision: null,
|
|
573
|
+
};
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const svc = this.config.services[service];
|
|
577
|
+
const dispatcher = this.dispatchers[service];
|
|
578
|
+
const policy = evaluateRoutePolicy(service, svc, {
|
|
579
|
+
dispatcher,
|
|
580
|
+
...(opts.safetyProfile !== undefined ? { requestedSafetyProfile: opts.safetyProfile } : {}),
|
|
581
|
+
...(opts.routePolicy !== undefined ? { routePolicy: opts.routePolicy } : {}),
|
|
582
|
+
});
|
|
583
|
+
if (policy.blocked) {
|
|
584
|
+
const result = {
|
|
585
|
+
output: "",
|
|
586
|
+
service,
|
|
587
|
+
success: false,
|
|
588
|
+
error: policy.skipped?.message ?? "Route blocked by policy",
|
|
589
|
+
};
|
|
590
|
+
if (policy.skipped)
|
|
591
|
+
result.skippedRoutes = [policy.skipped];
|
|
592
|
+
yield {
|
|
593
|
+
event: {
|
|
594
|
+
type: "completion",
|
|
595
|
+
result,
|
|
596
|
+
},
|
|
597
|
+
decision: null,
|
|
598
|
+
};
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const quotaScore = await this.quota.getQuotaScore(service);
|
|
602
|
+
const { qualityScore, elo } = await this.leaderboard.getQualityScore(svc.leaderboardModel, svc.thinkingLevel);
|
|
603
|
+
const taskType = opts.taskType ?? "";
|
|
604
|
+
const capScore = capabilityScore(svc, taskType);
|
|
605
|
+
const effectiveSafety = effectiveSafetyProfile(svc, opts.safetyProfile);
|
|
606
|
+
const decision = {
|
|
607
|
+
service,
|
|
608
|
+
tier: svc.tier,
|
|
609
|
+
quotaScore,
|
|
610
|
+
qualityScore,
|
|
611
|
+
cliCapability: svc.cliCapability,
|
|
612
|
+
capabilityScore: capScore,
|
|
613
|
+
taskType,
|
|
614
|
+
model: opts.model ?? resolveModel(svc, taskType),
|
|
615
|
+
elo: elo ?? undefined,
|
|
616
|
+
finalScore: qualityScore * svc.cliCapability * capScore * quotaScore * svc.weight,
|
|
617
|
+
reason: "explicit",
|
|
618
|
+
safetyProfile: requestedSafetyProfile(svc, opts.safetyProfile),
|
|
619
|
+
effectiveSafetyProfile: effectiveSafety,
|
|
620
|
+
billing: buildRouteBilling(svc),
|
|
621
|
+
workspacePolicy: workspacePolicyFor(svc, effectiveSafety, opts.workspacePolicy),
|
|
622
|
+
};
|
|
623
|
+
const dispatchOpts = {};
|
|
624
|
+
if (decision.model !== undefined)
|
|
625
|
+
dispatchOpts.modelOverride = decision.model;
|
|
626
|
+
if (decision.effectiveSafetyProfile !== undefined) {
|
|
627
|
+
dispatchOpts.safetyProfile = decision.effectiveSafetyProfile;
|
|
628
|
+
}
|
|
629
|
+
const effectiveTimeoutMs = opts.timeoutMs ?? svc.timeoutMs ?? opts.defaultTimeoutMs;
|
|
630
|
+
if (effectiveTimeoutMs !== undefined)
|
|
631
|
+
dispatchOpts.timeoutMs = effectiveTimeoutMs;
|
|
632
|
+
let finalResult = null;
|
|
633
|
+
for await (const event of streamWithWorkspacePolicy(svc, service, decision.effectiveSafetyProfile, decision.workspacePolicy, workingDir, files, (effectiveWorkingDir, effectiveFiles) => dispatcher.stream(prompt, effectiveFiles, effectiveWorkingDir, dispatchOpts))) {
|
|
634
|
+
yield { event, decision };
|
|
635
|
+
if (event.type === "completion")
|
|
636
|
+
finalResult = event.result;
|
|
637
|
+
}
|
|
638
|
+
if (finalResult === null) {
|
|
639
|
+
finalResult = {
|
|
640
|
+
output: "",
|
|
641
|
+
service,
|
|
642
|
+
success: false,
|
|
643
|
+
error: "Dispatcher stream ended without a completion event",
|
|
644
|
+
};
|
|
645
|
+
yield { event: { type: "completion", result: finalResult }, decision };
|
|
646
|
+
}
|
|
647
|
+
this.handleResult(service, finalResult, decision);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Route a task, with automatic fallback on transient failures.
|
|
651
|
+
*
|
|
652
|
+
* R3: reimplemented on top of `stream()`. The per-attempt result is
|
|
653
|
+
* captured from the `completion` event and drives the fallback loop.
|
|
654
|
+
*
|
|
655
|
+
* The old route() also had a quirk: when pickService returned null with
|
|
656
|
+
* no prior attempts, it yielded an error DispatchResult. On a later
|
|
657
|
+
* fallback round that returned null it returned the last attempt's
|
|
658
|
+
* result+decision. The streaming-based reimplementation below preserves
|
|
659
|
+
* the same externally observable behaviour for existing tests.
|
|
660
|
+
*/
|
|
661
|
+
async route(prompt, files, workingDir, opts = {}) {
|
|
662
|
+
return withRouterSpan({
|
|
663
|
+
"router.op": "route",
|
|
664
|
+
...(opts.hints?.taskType ? { task_type: opts.hints.taskType } : {}),
|
|
665
|
+
}, async (span) => {
|
|
666
|
+
const out = await this.#routeImpl(prompt, files, workingDir, opts);
|
|
667
|
+
if (out.decision) {
|
|
668
|
+
span.setAttribute("service", out.decision.service);
|
|
669
|
+
span.setAttribute("tier", out.decision.tier);
|
|
670
|
+
}
|
|
671
|
+
span.setAttribute("success", out.result.success);
|
|
672
|
+
return out;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
async #routeImpl(prompt, files, workingDir, opts = {}) {
|
|
676
|
+
const hints = opts.hints ?? {};
|
|
677
|
+
const maxFallbacks = opts.maxFallbacks ?? 2;
|
|
678
|
+
const tried = new Set();
|
|
679
|
+
let lastResult = null;
|
|
680
|
+
let lastDecision = null;
|
|
681
|
+
for (let attempt = 0; attempt <= maxFallbacks; attempt++) {
|
|
682
|
+
const decision = await this.pickService({
|
|
683
|
+
hints,
|
|
684
|
+
prompt,
|
|
685
|
+
files,
|
|
686
|
+
exclude: tried,
|
|
687
|
+
});
|
|
688
|
+
if (decision === null) {
|
|
689
|
+
if (lastResult !== null) {
|
|
690
|
+
return { result: lastResult, decision: lastDecision };
|
|
691
|
+
}
|
|
692
|
+
const breakerInfo = {};
|
|
693
|
+
for (const [name, b] of this.breakers)
|
|
694
|
+
breakerInfo[name] = b.status();
|
|
695
|
+
return {
|
|
696
|
+
result: {
|
|
697
|
+
output: "",
|
|
698
|
+
service: "none",
|
|
699
|
+
success: false,
|
|
700
|
+
error: "No available services — all are disabled, exhausted, or circuit-broken. " +
|
|
701
|
+
`Breaker state: ${JSON.stringify(breakerInfo)}`,
|
|
702
|
+
skippedRoutes: this.skippedRoutes(),
|
|
703
|
+
},
|
|
704
|
+
decision: null,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const dispatcher = this.dispatchers[decision.service];
|
|
708
|
+
const dispatchOpts = {};
|
|
709
|
+
if (decision.model !== undefined)
|
|
710
|
+
dispatchOpts.modelOverride = decision.model;
|
|
711
|
+
if (decision.effectiveSafetyProfile !== undefined) {
|
|
712
|
+
dispatchOpts.safetyProfile = decision.effectiveSafetyProfile;
|
|
713
|
+
}
|
|
714
|
+
{
|
|
715
|
+
const effectiveTimeoutMs = hints.timeoutMs ?? this.config.services[decision.service].timeoutMs;
|
|
716
|
+
if (effectiveTimeoutMs !== undefined)
|
|
717
|
+
dispatchOpts.timeoutMs = effectiveTimeoutMs;
|
|
718
|
+
}
|
|
719
|
+
// Prefer the buffered dispatch path when it's available — many R1/R2
|
|
720
|
+
// tests assert on dispatcher.dispatch being called once; if we always
|
|
721
|
+
// went through stream() those assertions would break. Dispatchers that
|
|
722
|
+
// extend BaseDispatcher still ultimately funnel through stream(), but
|
|
723
|
+
// dispatchers (like OpenAICompatibleDispatcher) that override dispatch
|
|
724
|
+
// keep their fast-path.
|
|
725
|
+
const spanAttrs = {
|
|
726
|
+
"dispatcher.id": decision.service,
|
|
727
|
+
};
|
|
728
|
+
if (decision.model !== undefined)
|
|
729
|
+
spanAttrs.model = decision.model;
|
|
730
|
+
if (decision.taskType)
|
|
731
|
+
spanAttrs["task_type"] = decision.taskType;
|
|
732
|
+
const result = await withWorkspacePolicy(this.config.services[decision.service], decision.service, decision.effectiveSafetyProfile, decision.workspacePolicy, workingDir, files, (effectiveWorkingDir, effectiveFiles) => withDispatcherSpan("dispatch", spanAttrs, async (span) => {
|
|
733
|
+
const r = await dispatcher.dispatch(prompt, effectiveFiles, effectiveWorkingDir, dispatchOpts);
|
|
734
|
+
span.setAttribute("success", r.success);
|
|
735
|
+
if (r.rateLimited)
|
|
736
|
+
span.setAttribute("rate_limited", true);
|
|
737
|
+
if (r.tokensUsed) {
|
|
738
|
+
span.setAttribute("tokens.input", r.tokensUsed.input);
|
|
739
|
+
span.setAttribute("tokens.output", r.tokensUsed.output);
|
|
740
|
+
}
|
|
741
|
+
return r;
|
|
742
|
+
}));
|
|
743
|
+
this.handleResult(decision.service, result, decision);
|
|
744
|
+
lastResult = result;
|
|
745
|
+
lastDecision = decision;
|
|
746
|
+
if (result.success) {
|
|
747
|
+
if (attempt > 0)
|
|
748
|
+
decision.reason += ` (fallback #${attempt} — prev failed)`;
|
|
749
|
+
return { result, decision };
|
|
750
|
+
}
|
|
751
|
+
// Rate-limited and transient failures alike: handleResult already
|
|
752
|
+
// tripped the breaker; exclude this service and fall back to the
|
|
753
|
+
// next-best candidate rather than aborting the caller's request.
|
|
754
|
+
tried.add(decision.service);
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
result: lastResult ??
|
|
758
|
+
{
|
|
759
|
+
output: "",
|
|
760
|
+
service: "none",
|
|
761
|
+
success: false,
|
|
762
|
+
error: "Router exhausted all fallback attempts.",
|
|
763
|
+
},
|
|
764
|
+
decision: lastDecision,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Dispatch to a specific service, bypassing tier selection.
|
|
769
|
+
*/
|
|
770
|
+
async routeTo(service, prompt, files, workingDir, opts = {}) {
|
|
771
|
+
if (!(service in this.dispatchers)) {
|
|
772
|
+
return {
|
|
773
|
+
result: {
|
|
774
|
+
output: "",
|
|
775
|
+
service,
|
|
776
|
+
success: false,
|
|
777
|
+
error: unknownServiceError(service, Object.keys(this.dispatchers)),
|
|
778
|
+
},
|
|
779
|
+
decision: null,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const breaker = this.breakers.get(service);
|
|
783
|
+
if (breaker && breaker.isTripped) {
|
|
784
|
+
const cd = Math.round(breaker.cooldownRemaining() * 10) / 10;
|
|
785
|
+
return {
|
|
786
|
+
result: {
|
|
787
|
+
output: "",
|
|
788
|
+
service,
|
|
789
|
+
success: false,
|
|
790
|
+
error: `'${service}' is circuit-broken — ${cd}s cooldown remaining`,
|
|
791
|
+
},
|
|
792
|
+
decision: null,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
const svc = this.config.services[service];
|
|
796
|
+
const dispatcher = this.dispatchers[service];
|
|
797
|
+
const policy = evaluateRoutePolicy(service, svc, {
|
|
798
|
+
dispatcher,
|
|
799
|
+
...(opts.safetyProfile !== undefined ? { requestedSafetyProfile: opts.safetyProfile } : {}),
|
|
800
|
+
...(opts.routePolicy !== undefined ? { routePolicy: opts.routePolicy } : {}),
|
|
801
|
+
});
|
|
802
|
+
if (policy.blocked) {
|
|
803
|
+
const result = {
|
|
804
|
+
output: "",
|
|
805
|
+
service,
|
|
806
|
+
success: false,
|
|
807
|
+
error: policy.skipped?.message ?? "Route blocked by policy",
|
|
808
|
+
};
|
|
809
|
+
if (policy.skipped)
|
|
810
|
+
result.skippedRoutes = [policy.skipped];
|
|
811
|
+
return {
|
|
812
|
+
result,
|
|
813
|
+
decision: null,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
const quotaScore = await this.quota.getQuotaScore(service);
|
|
817
|
+
const { qualityScore, elo } = await this.leaderboard.getQualityScore(svc.leaderboardModel, svc.thinkingLevel);
|
|
818
|
+
const taskType = opts.taskType ?? "";
|
|
819
|
+
const capScore = capabilityScore(svc, taskType);
|
|
820
|
+
const effectiveSafety = effectiveSafetyProfile(svc, opts.safetyProfile);
|
|
821
|
+
const decision = {
|
|
822
|
+
service,
|
|
823
|
+
tier: svc.tier,
|
|
824
|
+
quotaScore,
|
|
825
|
+
qualityScore,
|
|
826
|
+
cliCapability: svc.cliCapability,
|
|
827
|
+
capabilityScore: capScore,
|
|
828
|
+
taskType,
|
|
829
|
+
model: opts.model ?? resolveModel(svc, taskType),
|
|
830
|
+
elo: elo ?? undefined,
|
|
831
|
+
finalScore: qualityScore * svc.cliCapability * capScore * quotaScore * svc.weight,
|
|
832
|
+
reason: "explicit",
|
|
833
|
+
safetyProfile: requestedSafetyProfile(svc, opts.safetyProfile),
|
|
834
|
+
effectiveSafetyProfile: effectiveSafety,
|
|
835
|
+
billing: buildRouteBilling(svc),
|
|
836
|
+
workspacePolicy: workspacePolicyFor(svc, effectiveSafety, opts.workspacePolicy),
|
|
837
|
+
};
|
|
838
|
+
const dispatchOpts = {};
|
|
839
|
+
if (decision.model !== undefined)
|
|
840
|
+
dispatchOpts.modelOverride = decision.model;
|
|
841
|
+
if (decision.effectiveSafetyProfile !== undefined) {
|
|
842
|
+
dispatchOpts.safetyProfile = decision.effectiveSafetyProfile;
|
|
843
|
+
}
|
|
844
|
+
{
|
|
845
|
+
const effectiveTimeoutMs = opts.timeoutMs ?? svc.timeoutMs;
|
|
846
|
+
if (effectiveTimeoutMs !== undefined)
|
|
847
|
+
dispatchOpts.timeoutMs = effectiveTimeoutMs;
|
|
848
|
+
}
|
|
849
|
+
const result = await withWorkspacePolicy(svc, service, decision.effectiveSafetyProfile, decision.workspacePolicy, workingDir, files, (effectiveWorkingDir, effectiveFiles) => this.dispatchers[service].dispatch(prompt, effectiveFiles, effectiveWorkingDir, dispatchOpts));
|
|
850
|
+
this.handleResult(service, result, decision);
|
|
851
|
+
return { result, decision };
|
|
852
|
+
}
|
|
853
|
+
handleResult(service, result, decision) {
|
|
854
|
+
logDispatch(service, result, decision);
|
|
855
|
+
this.quota.recordResult(service, result);
|
|
856
|
+
const breaker = this.breakers.get(service);
|
|
857
|
+
if (!breaker)
|
|
858
|
+
return;
|
|
859
|
+
if (result.success) {
|
|
860
|
+
breaker.recordSuccess();
|
|
861
|
+
}
|
|
862
|
+
else if (result.rateLimited) {
|
|
863
|
+
breaker.trip(result.retryAfter);
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
breaker.recordFailure(result.retryAfter);
|
|
867
|
+
}
|
|
868
|
+
this.persistBreaker(service);
|
|
869
|
+
}
|
|
870
|
+
circuitBreakerStatus() {
|
|
871
|
+
const out = {};
|
|
872
|
+
for (const [name, b] of this.breakers)
|
|
873
|
+
out[name] = b.status();
|
|
874
|
+
return out;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
export { drainDispatcherStream };
|
|
878
|
+
//# sourceMappingURL=router.js.map
|