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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +582 -0
  3. package/config.default.yaml +397 -0
  4. package/data/coding_benchmarks.json +431 -0
  5. package/dist/auth.d.ts +9 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +73 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/billing.d.ts +7 -0
  10. package/dist/billing.d.ts.map +1 -0
  11. package/dist/billing.js +201 -0
  12. package/dist/billing.js.map +1 -0
  13. package/dist/bin.d.ts +6 -0
  14. package/dist/bin.d.ts.map +1 -0
  15. package/dist/bin.js +488 -0
  16. package/dist/bin.js.map +1 -0
  17. package/dist/breaker-store.d.ts +31 -0
  18. package/dist/breaker-store.d.ts.map +1 -0
  19. package/dist/breaker-store.js +103 -0
  20. package/dist/breaker-store.js.map +1 -0
  21. package/dist/circuit-breaker.d.ts +47 -0
  22. package/dist/circuit-breaker.d.ts.map +1 -0
  23. package/dist/circuit-breaker.js +102 -0
  24. package/dist/circuit-breaker.js.map +1 -0
  25. package/dist/config.d.ts +48 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +1248 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/dashboard/live.d.ts +49 -0
  30. package/dist/dashboard/live.d.ts.map +1 -0
  31. package/dist/dashboard/live.js +149 -0
  32. package/dist/dashboard/live.js.map +1 -0
  33. package/dist/dispatch-log.d.ts +46 -0
  34. package/dist/dispatch-log.d.ts.map +1 -0
  35. package/dist/dispatch-log.js +89 -0
  36. package/dist/dispatch-log.js.map +1 -0
  37. package/dist/dispatchers/base.d.ts +55 -0
  38. package/dist/dispatchers/base.d.ts.map +1 -0
  39. package/dist/dispatchers/base.js +80 -0
  40. package/dist/dispatchers/base.js.map +1 -0
  41. package/dist/dispatchers/generic-cli.d.ts +30 -0
  42. package/dist/dispatchers/generic-cli.d.ts.map +1 -0
  43. package/dist/dispatchers/generic-cli.js +471 -0
  44. package/dist/dispatchers/generic-cli.js.map +1 -0
  45. package/dist/dispatchers/openai-compatible.d.ts +57 -0
  46. package/dist/dispatchers/openai-compatible.d.ts.map +1 -0
  47. package/dist/dispatchers/openai-compatible.js +662 -0
  48. package/dist/dispatchers/openai-compatible.js.map +1 -0
  49. package/dist/dispatchers/shared/kill-tree.d.ts +21 -0
  50. package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -0
  51. package/dist/dispatchers/shared/kill-tree.js +35 -0
  52. package/dist/dispatchers/shared/kill-tree.js.map +1 -0
  53. package/dist/dispatchers/shared/rate-limit-headers.d.ts +22 -0
  54. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -0
  55. package/dist/dispatchers/shared/rate-limit-headers.js +124 -0
  56. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -0
  57. package/dist/dispatchers/shared/stream-subprocess.d.ts +53 -0
  58. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -0
  59. package/dist/dispatchers/shared/stream-subprocess.js +274 -0
  60. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -0
  61. package/dist/dispatchers/shared/subprocess.d.ts +25 -0
  62. package/dist/dispatchers/shared/subprocess.d.ts.map +1 -0
  63. package/dist/dispatchers/shared/subprocess.js +110 -0
  64. package/dist/dispatchers/shared/subprocess.js.map +1 -0
  65. package/dist/dispatchers/shared/which-available.d.ts +2 -0
  66. package/dist/dispatchers/shared/which-available.d.ts.map +1 -0
  67. package/dist/dispatchers/shared/which-available.js +8 -0
  68. package/dist/dispatchers/shared/which-available.js.map +1 -0
  69. package/dist/dispatchers/shared/windows-cmd.d.ts +38 -0
  70. package/dist/dispatchers/shared/windows-cmd.d.ts.map +1 -0
  71. package/dist/dispatchers/shared/windows-cmd.js +91 -0
  72. package/dist/dispatchers/shared/windows-cmd.js.map +1 -0
  73. package/dist/http/server.d.ts +15 -0
  74. package/dist/http/server.d.ts.map +1 -0
  75. package/dist/http/server.js +495 -0
  76. package/dist/http/server.js.map +1 -0
  77. package/dist/index.d.ts +27 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +29 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/job-runner.d.ts +19 -0
  82. package/dist/job-runner.d.ts.map +1 -0
  83. package/dist/job-runner.js +42 -0
  84. package/dist/job-runner.js.map +1 -0
  85. package/dist/jobs.d.ts +116 -0
  86. package/dist/jobs.d.ts.map +1 -0
  87. package/dist/jobs.js +542 -0
  88. package/dist/jobs.js.map +1 -0
  89. package/dist/leaderboard.d.ts +62 -0
  90. package/dist/leaderboard.d.ts.map +1 -0
  91. package/dist/leaderboard.js +287 -0
  92. package/dist/leaderboard.js.map +1 -0
  93. package/dist/mcp/config-hot-reload.d.ts +64 -0
  94. package/dist/mcp/config-hot-reload.d.ts.map +1 -0
  95. package/dist/mcp/config-hot-reload.js +144 -0
  96. package/dist/mcp/config-hot-reload.js.map +1 -0
  97. package/dist/mcp/dispatcher-factory.d.ts +20 -0
  98. package/dist/mcp/dispatcher-factory.d.ts.map +1 -0
  99. package/dist/mcp/dispatcher-factory.js +30 -0
  100. package/dist/mcp/dispatcher-factory.js.map +1 -0
  101. package/dist/mcp/resources.d.ts +8 -0
  102. package/dist/mcp/resources.d.ts.map +1 -0
  103. package/dist/mcp/resources.js +45 -0
  104. package/dist/mcp/resources.js.map +1 -0
  105. package/dist/mcp/server.d.ts +36 -0
  106. package/dist/mcp/server.d.ts.map +1 -0
  107. package/dist/mcp/server.js +80 -0
  108. package/dist/mcp/server.js.map +1 -0
  109. package/dist/mcp/tools.d.ts +171 -0
  110. package/dist/mcp/tools.d.ts.map +1 -0
  111. package/dist/mcp/tools.js +658 -0
  112. package/dist/mcp/tools.js.map +1 -0
  113. package/dist/observability/index.d.ts +52 -0
  114. package/dist/observability/index.d.ts.map +1 -0
  115. package/dist/observability/index.js +111 -0
  116. package/dist/observability/index.js.map +1 -0
  117. package/dist/observability/spans.d.ts +47 -0
  118. package/dist/observability/spans.d.ts.map +1 -0
  119. package/dist/observability/spans.js +72 -0
  120. package/dist/observability/spans.js.map +1 -0
  121. package/dist/quota.d.ts +91 -0
  122. package/dist/quota.d.ts.map +1 -0
  123. package/dist/quota.js +331 -0
  124. package/dist/quota.js.map +1 -0
  125. package/dist/route-policy.d.ts +24 -0
  126. package/dist/route-policy.d.ts.map +1 -0
  127. package/dist/route-policy.js +92 -0
  128. package/dist/route-policy.js.map +1 -0
  129. package/dist/router.d.ts +162 -0
  130. package/dist/router.d.ts.map +1 -0
  131. package/dist/router.js +878 -0
  132. package/dist/router.js.map +1 -0
  133. package/dist/safety.d.ts +7 -0
  134. package/dist/safety.d.ts.map +1 -0
  135. package/dist/safety.js +33 -0
  136. package/dist/safety.js.map +1 -0
  137. package/dist/status.d.ts +100 -0
  138. package/dist/status.d.ts.map +1 -0
  139. package/dist/status.js +228 -0
  140. package/dist/status.js.map +1 -0
  141. package/dist/types.d.ts +402 -0
  142. package/dist/types.d.ts.map +1 -0
  143. package/dist/types.js +9 -0
  144. package/dist/types.js.map +1 -0
  145. package/dist/version.d.ts +2 -0
  146. package/dist/version.d.ts.map +1 -0
  147. package/dist/version.js +13 -0
  148. package/dist/version.js.map +1 -0
  149. package/dist/working-dir.d.ts +15 -0
  150. package/dist/working-dir.d.ts.map +1 -0
  151. package/dist/working-dir.js +22 -0
  152. package/dist/working-dir.js.map +1 -0
  153. package/dist/workspaces.d.ts +19 -0
  154. package/dist/workspaces.d.ts.map +1 -0
  155. package/dist/workspaces.js +352 -0
  156. package/dist/workspaces.js.map +1 -0
  157. 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