cascade-ai 0.72.0 → 0.74.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/dist/index.cjs CHANGED
@@ -1792,6 +1792,15 @@ function findPricing(modelId, provider, opts = {}) {
1792
1792
  }
1793
1793
  return null;
1794
1794
  }
1795
+ function hasContextBands(model) {
1796
+ if (model.isLocal) return false;
1797
+ const fallbackProviders = model.provider === "azure" ? ["openai"] : model.provider === "gemini" ? ["vertex"] : [];
1798
+ const match = findPricing(model.id, model.provider, {
1799
+ baseModelId: model.baseModelId,
1800
+ fallbackProviders
1801
+ });
1802
+ return (match?.entry.tiers?.length ?? 0) > 1;
1803
+ }
1795
1804
  function tierFor(entry, inputTokens = 0) {
1796
1805
  const tiers = entry.tiers;
1797
1806
  if (!tiers?.length) return null;
@@ -1908,7 +1917,7 @@ function reconcilePrice(model, live) {
1908
1917
  }
1909
1918
 
1910
1919
  // src/constants.ts
1911
- var CASCADE_VERSION = "0.72.0";
1920
+ var CASCADE_VERSION = "0.74.0";
1912
1921
  var CASCADE_CONFIG_DIR = ".cascade";
1913
1922
  var CASCADE_MD_FILE = "CASCADE.md";
1914
1923
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -2353,8 +2362,23 @@ var PROVIDER_DISPLAY_NAMES = {
2353
2362
  };
2354
2363
 
2355
2364
  // src/utils/cost.ts
2356
- function resolveModelPricing(model) {
2365
+ function resolveModelPricing(model, opts = {}) {
2357
2366
  if (model.isLocal) return { input: 0, output: 0, unknown: false };
2367
+ if (opts.inputTokens != null && hasContextBands(model)) {
2368
+ const banded = resolvePricing(model, { inputTokens: opts.inputTokens });
2369
+ const base = resolvePricing(model, { inputTokens: 0 });
2370
+ if (!banded.unknown && !base.unknown) {
2371
+ const scale = (stamped, baseRate) => stamped > 0 && baseRate > 0 ? stamped / baseRate : 1;
2372
+ const inScale = scale(model.inputCostPer1kTokens, base.input);
2373
+ const outScale = scale(model.outputCostPer1kTokens, base.output);
2374
+ return {
2375
+ input: banded.input * inScale,
2376
+ output: banded.output * outScale,
2377
+ unknown: false,
2378
+ ...banded.estimatedFromProvider ? { estimatedFromProvider: banded.estimatedFromProvider } : {}
2379
+ };
2380
+ }
2381
+ }
2358
2382
  if (model.inputCostPer1kTokens > 0 || model.outputCostPer1kTokens > 0) {
2359
2383
  return {
2360
2384
  input: model.inputCostPer1kTokens,
@@ -2362,7 +2386,7 @@ function resolveModelPricing(model) {
2362
2386
  unknown: false
2363
2387
  };
2364
2388
  }
2365
- const fromDataset = resolvePricing(model);
2389
+ const fromDataset = resolvePricing(model, { inputTokens: opts.inputTokens });
2366
2390
  if (!fromDataset.unknown) {
2367
2391
  return {
2368
2392
  input: fromDataset.input,
@@ -2384,11 +2408,11 @@ function resolveModelPricing(model) {
2384
2408
  return { input: 0, output: 0, unknown: true };
2385
2409
  }
2386
2410
  function calculateCost(inputTokens, outputTokens, model) {
2387
- const { input, output } = resolveModelPricing(model);
2411
+ const { input, output } = resolveModelPricing(model, { inputTokens });
2388
2412
  return inputTokens / 1e3 * input + outputTokens / 1e3 * output;
2389
2413
  }
2390
2414
  function buildTokenUsage(inputTokens, outputTokens, model) {
2391
- const { input, output, unknown } = resolveModelPricing(model);
2415
+ const { input, output, unknown } = resolveModelPricing(model, { inputTokens });
2392
2416
  return {
2393
2417
  inputTokens,
2394
2418
  outputTokens,
@@ -2399,6 +2423,13 @@ function buildTokenUsage(inputTokens, outputTokens, model) {
2399
2423
  }
2400
2424
 
2401
2425
  // src/providers/base.ts
2426
+ var ProviderUnreachableError = class extends Error {
2427
+ transient = true;
2428
+ constructor(message) {
2429
+ super(message);
2430
+ this.name = "ProviderUnreachableError";
2431
+ }
2432
+ };
2402
2433
  var BaseProvider = class {
2403
2434
  config;
2404
2435
  model;
@@ -2467,6 +2498,21 @@ function anthropicThinkingParam(modelId, maxTokens) {
2467
2498
  if (budget < 1024) return {};
2468
2499
  return { thinking: { type: "enabled", budget_tokens: budget } };
2469
2500
  }
2501
+ function toAnthropicTools(tools) {
2502
+ return tools.map((t) => ({
2503
+ name: t.name,
2504
+ description: t.description,
2505
+ input_schema: t.inputSchema
2506
+ }));
2507
+ }
2508
+ function toAnthropicToolUse(toolCalls) {
2509
+ return toolCalls.map((tc) => ({
2510
+ type: "tool_use",
2511
+ id: tc.id,
2512
+ name: tc.name,
2513
+ input: tc.input
2514
+ }));
2515
+ }
2470
2516
  var AnthropicProvider = class extends BaseProvider {
2471
2517
  client;
2472
2518
  constructor(config, model) {
@@ -2488,11 +2534,7 @@ var AnthropicProvider = class extends BaseProvider {
2488
2534
  }
2489
2535
  async generateStream(options, onChunk) {
2490
2536
  const messages = this.convertMessages(options.messages);
2491
- const tools = options.tools?.map((t) => ({
2492
- name: t.name,
2493
- description: t.description,
2494
- input_schema: t.inputSchema
2495
- }));
2537
+ const tools = options.tools && toAnthropicTools(options.tools);
2496
2538
  let fullContent = "";
2497
2539
  let inputTokens = 0;
2498
2540
  let outputTokens = 0;
@@ -2620,14 +2662,7 @@ var AnthropicProvider = class extends BaseProvider {
2620
2662
  const content = [];
2621
2663
  const text = typeof m.content === "string" ? m.content : "";
2622
2664
  if (text) content.push({ type: "text", text });
2623
- for (const tc of m.toolCalls ?? []) {
2624
- content.push({
2625
- type: "tool_use",
2626
- id: tc.id,
2627
- name: tc.name,
2628
- input: tc.input
2629
- });
2630
- }
2665
+ content.push(...toAnthropicToolUse(m.toolCalls ?? []));
2631
2666
  if (content.length > 0) {
2632
2667
  result.push({ role: "assistant", content });
2633
2668
  }
@@ -2667,6 +2702,26 @@ function isParamShapeError(err) {
2667
2702
  const msg = String(e?.message ?? e?.error?.message ?? "").toLowerCase();
2668
2703
  return msg.includes("max_completion_tokens") || msg.includes("max_tokens") && (msg.includes("unsupported") || msg.includes("not supported") || msg.includes("use")) || msg.includes("temperature") && (msg.includes("unsupported") || msg.includes("does not support") || msg.includes("only the default"));
2669
2704
  }
2705
+ function toOpenAITools(tools) {
2706
+ return tools.map((t) => ({
2707
+ type: "function",
2708
+ function: {
2709
+ name: t.name,
2710
+ description: t.description,
2711
+ parameters: t.inputSchema
2712
+ }
2713
+ }));
2714
+ }
2715
+ function toOpenAIToolCalls(toolCalls) {
2716
+ return toolCalls.map((tc) => ({
2717
+ id: tc.id,
2718
+ type: "function",
2719
+ function: {
2720
+ name: tc.name,
2721
+ arguments: JSON.stringify(tc.input)
2722
+ }
2723
+ }));
2724
+ }
2670
2725
  var OpenAIProvider = class extends BaseProvider {
2671
2726
  client;
2672
2727
  /** Once we learn (from the model id or an API error) that this deployment
@@ -2686,14 +2741,7 @@ var OpenAIProvider = class extends BaseProvider {
2686
2741
  }
2687
2742
  async generateStream(options, onChunk) {
2688
2743
  const messages = this.convertMessages(options.messages, options.systemPrompt);
2689
- const tools = options.tools?.map((t) => ({
2690
- type: "function",
2691
- function: {
2692
- name: t.name,
2693
- description: t.description,
2694
- parameters: t.inputSchema
2695
- }
2696
- }));
2744
+ const tools = options.tools && toOpenAITools(options.tools);
2697
2745
  let fullContent = "";
2698
2746
  let inputTokens = 0;
2699
2747
  let outputTokens = 0;
@@ -2839,14 +2887,7 @@ var OpenAIProvider = class extends BaseProvider {
2839
2887
  result.push({
2840
2888
  role: "assistant",
2841
2889
  content: m.content || "",
2842
- tool_calls: m.toolCalls.map((toolCall) => ({
2843
- id: toolCall.id,
2844
- type: "function",
2845
- function: {
2846
- name: toolCall.name,
2847
- arguments: JSON.stringify(toolCall.input)
2848
- }
2849
- }))
2890
+ tool_calls: toOpenAIToolCalls(m.toolCalls)
2850
2891
  });
2851
2892
  } else {
2852
2893
  result.push({ role: m.role, content: m.content });
@@ -3089,6 +3130,38 @@ function toGeminiParameters(inputSchema) {
3089
3130
  }
3090
3131
 
3091
3132
  // src/providers/gemini.ts
3133
+ function toGeminiTools(tools) {
3134
+ return tools.map((tool) => ({
3135
+ name: tool.name,
3136
+ description: tool.description,
3137
+ // Convert, never cast. Gemini's parameters field is an OpenAPI subset
3138
+ // that 400s on unknown keys, and MCP servers ship schemas carrying their
3139
+ // own extensions (GitHub's adds `x-mcp-header` to every header-bound
3140
+ // property). Casting sent those through verbatim and every request failed
3141
+ // before the model saw it. See providers/gemini-schema.ts.
3142
+ parameters: toGeminiParameters(tool.inputSchema)
3143
+ }));
3144
+ }
3145
+ function toGeminiFunctionCalls(toolCalls) {
3146
+ return toolCalls.map((tc) => ({
3147
+ functionCall: {
3148
+ name: tc.name,
3149
+ args: tc.input
3150
+ }
3151
+ }));
3152
+ }
3153
+ function isGeminiAuthStatus(status) {
3154
+ return status === 400 || status === 401 || status === 403;
3155
+ }
3156
+ async function describeGeminiError(resp) {
3157
+ try {
3158
+ const body = await resp.json();
3159
+ const detail = body?.error?.message ?? body?.error?.status;
3160
+ return detail ? ` \u2014 ${detail}` : "";
3161
+ } catch {
3162
+ return "";
3163
+ }
3164
+ }
3092
3165
  var GeminiProvider = class extends BaseProvider {
3093
3166
  client;
3094
3167
  constructor(config, model) {
@@ -3110,7 +3183,7 @@ var GeminiProvider = class extends BaseProvider {
3110
3183
  { category: genai.HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: genai.HarmBlockThreshold.BLOCK_NONE },
3111
3184
  { category: genai.HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: genai.HarmBlockThreshold.BLOCK_NONE }
3112
3185
  ],
3113
- tools: options.tools?.length ? [{ functionDeclarations: options.tools.map(this.convertTool) }] : void 0,
3186
+ tools: options.tools?.length ? [{ functionDeclarations: toGeminiTools(options.tools) }] : void 0,
3114
3187
  abortSignal: options.signal
3115
3188
  }
3116
3189
  });
@@ -3172,9 +3245,7 @@ var GeminiProvider = class extends BaseProvider {
3172
3245
  }
3173
3246
  async listModels() {
3174
3247
  try {
3175
- const resp = await fetch(
3176
- `https://generativelanguage.googleapis.com/v1beta/models?key=${this.config.apiKey}`
3177
- );
3248
+ const resp = await this.fetchModelList();
3178
3249
  if (!resp.ok) {
3179
3250
  return Object.values(MODELS).filter((m) => m.provider === "gemini");
3180
3251
  }
@@ -3205,18 +3276,61 @@ var GeminiProvider = class extends BaseProvider {
3205
3276
  return Object.values(MODELS).filter((m) => m.provider === "gemini");
3206
3277
  }
3207
3278
  }
3279
+ /**
3280
+ * Is this KEY usable — not "does one particular model answer".
3281
+ *
3282
+ * This used to call countTokens() against `this.model.id`, which the router
3283
+ * fills from the first Gemini entry in the bundled catalogue. That made the
3284
+ * whole provider's fate depend on one hard-coded model id: a key that cannot
3285
+ * reach that one model — retired, not enabled for the key's project, served
3286
+ * on a different API version — failed the probe, and because every later step
3287
+ * is gated on the result (`validateCloudProviderModels` and
3288
+ * `discoverProviderModels` both return early for an unavailable provider) the
3289
+ * real model list was never fetched. Gemini vanished entirely, and the CLI
3290
+ * reported "add a provider API key first" about a key that was present and
3291
+ * working.
3292
+ *
3293
+ * Listing models asks the account-level question instead, the same one
3294
+ * OpenAI's probe asks, and it is the same request `listModels()` already
3295
+ * makes for discovery.
3296
+ *
3297
+ * Throws rather than returning false. The router logs the reason it catches,
3298
+ * and "bad key, wrong endpoint/deployment, or unreachable" — three guesses,
3299
+ * no answer — is what made this take a user report to find. A swallowed probe
3300
+ * error is the same defect that once hid a misconfigured Azure deployment.
3301
+ */
3208
3302
  async isAvailable() {
3303
+ if (!this.config.apiKey) throw new Error("no Gemini API key configured");
3304
+ let resp;
3209
3305
  try {
3210
- await this.client.models.countTokens({
3211
- model: this.model.id,
3212
- contents: [{ role: "user", parts: [{ text: "ping" }] }]
3213
- });
3214
- return true;
3215
- } catch {
3216
- return false;
3306
+ resp = await this.fetchModelList();
3307
+ } catch (err) {
3308
+ throw new ProviderUnreachableError(
3309
+ `could not reach the Gemini API: ${err instanceof Error ? err.message : String(err)}`
3310
+ );
3311
+ }
3312
+ if (resp.ok) return true;
3313
+ const status = `HTTP ${resp.status} ${resp.statusText}`.trim();
3314
+ const detail = await describeGeminiError(resp);
3315
+ if (isGeminiAuthStatus(resp.status)) {
3316
+ throw new Error(`Gemini rejected the API key: ${status}${detail}`);
3217
3317
  }
3318
+ throw new ProviderUnreachableError(`Gemini model list failed: ${status}${detail}`);
3218
3319
  }
3219
3320
  // ── Private ──────────────────────────────────
3321
+ /**
3322
+ * The account's model list. One place so the availability probe and
3323
+ * discovery cannot ask different questions of the same endpoint.
3324
+ *
3325
+ * The key goes in a header, not the query string: a URL carries into proxy
3326
+ * logs, error reports and shell history, and this one would carry the key
3327
+ * with it.
3328
+ */
3329
+ fetchModelList() {
3330
+ return fetch("https://generativelanguage.googleapis.com/v1beta/models", {
3331
+ headers: { "x-goog-api-key": this.config.apiKey ?? "" }
3332
+ });
3333
+ }
3220
3334
  buildContents(messages, extraImages) {
3221
3335
  const contents = [];
3222
3336
  for (const m of messages) {
@@ -3251,14 +3365,7 @@ var GeminiProvider = class extends BaseProvider {
3251
3365
  const parts = [];
3252
3366
  const textContent = typeof m.content === "string" ? m.content : "";
3253
3367
  if (textContent) parts.push({ text: textContent });
3254
- for (const tc of m.toolCalls ?? []) {
3255
- parts.push({
3256
- functionCall: {
3257
- name: tc.name,
3258
- args: tc.input
3259
- }
3260
- });
3261
- }
3368
+ parts.push(...toGeminiFunctionCalls(m.toolCalls ?? []));
3262
3369
  if (parts.length > 0) {
3263
3370
  contents.push({ role: "model", parts });
3264
3371
  }
@@ -3305,18 +3412,6 @@ var GeminiProvider = class extends BaseProvider {
3305
3412
  }
3306
3413
  return parts;
3307
3414
  }
3308
- convertTool(tool) {
3309
- return {
3310
- name: tool.name,
3311
- description: tool.description,
3312
- // Convert, never cast. Gemini's parameters field is an OpenAPI subset
3313
- // that 400s on unknown keys, and MCP servers ship schemas carrying their
3314
- // own extensions (GitHub's adds `x-mcp-header` to every header-bound
3315
- // property). Casting sent those through verbatim and every request failed
3316
- // before the model saw it. See providers/gemini-schema.ts.
3317
- parameters: toGeminiParameters(tool.inputSchema)
3318
- };
3319
- }
3320
3415
  };
3321
3416
  try {
3322
3417
  dns__default.default.setDefaultResultOrder("ipv4first");
@@ -3409,6 +3504,24 @@ function isToolCapable(modelName) {
3409
3504
  const name = modelName.toLowerCase();
3410
3505
  return TOOL_CAPABLE_FAMILIES.some((family) => name.includes(family));
3411
3506
  }
3507
+ function toOllamaTools(tools) {
3508
+ return tools.map((t) => ({
3509
+ type: "function",
3510
+ function: {
3511
+ name: t.name,
3512
+ description: t.description,
3513
+ parameters: t.inputSchema
3514
+ }
3515
+ }));
3516
+ }
3517
+ function toOllamaToolCalls(toolCalls) {
3518
+ return toolCalls.map((tc) => ({
3519
+ function: {
3520
+ name: tc.name,
3521
+ arguments: tc.input
3522
+ }
3523
+ }));
3524
+ }
3412
3525
  var OllamaProvider = class extends BaseProvider {
3413
3526
  baseUrl;
3414
3527
  constructor(config, model) {
@@ -3421,14 +3534,7 @@ var OllamaProvider = class extends BaseProvider {
3421
3534
  }
3422
3535
  async generateStream(options, onChunk) {
3423
3536
  const messages = this.convertMessages(options.messages, options.systemPrompt);
3424
- const ollamaTools = options.tools?.map((t) => ({
3425
- type: "function",
3426
- function: {
3427
- name: t.name,
3428
- description: t.description,
3429
- parameters: t.inputSchema
3430
- }
3431
- }));
3537
+ const ollamaTools = options.tools && toOllamaTools(options.tools);
3432
3538
  const response = await fetch(`${this.baseUrl}/api/chat`, {
3433
3539
  method: "POST",
3434
3540
  headers: { "Content-Type": "application/json" },
@@ -3608,12 +3714,7 @@ var OllamaProvider = class extends BaseProvider {
3608
3714
  result.push({
3609
3715
  role: "assistant",
3610
3716
  content: typeof m.content === "string" ? m.content : "",
3611
- tool_calls: m.toolCalls.map((tc) => ({
3612
- function: {
3613
- name: tc.name,
3614
- arguments: tc.input
3615
- }
3616
- }))
3717
+ tool_calls: toOllamaToolCalls(m.toolCalls)
3617
3718
  });
3618
3719
  continue;
3619
3720
  }
@@ -4014,6 +4115,21 @@ var DEFAULT_PROVIDER_TPM = {
4014
4115
  "openai-compatible": 3e4,
4015
4116
  ollama: Number.POSITIVE_INFINITY
4016
4117
  };
4118
+ function abortReason(signal) {
4119
+ return signal.reason instanceof Error ? signal.reason : new Error("Aborted while waiting for rate-limit capacity");
4120
+ }
4121
+ function sleepUnlessAborted(ms, signal) {
4122
+ if (!signal) return new Promise((resolve2) => setTimeout(resolve2, ms));
4123
+ return new Promise((resolve2) => {
4124
+ const done = () => {
4125
+ clearTimeout(timer);
4126
+ signal.removeEventListener("abort", done);
4127
+ resolve2();
4128
+ };
4129
+ const timer = setTimeout(done, ms);
4130
+ signal.addEventListener("abort", done, { once: true });
4131
+ });
4132
+ }
4017
4133
  var TpmLimiter = class {
4018
4134
  buckets = /* @__PURE__ */ new Map();
4019
4135
  constructor(overrides = {}) {
@@ -4032,9 +4148,10 @@ var TpmLimiter = class {
4032
4148
  * are reported back via `refund` when short, or simply settled at the next
4033
4149
  * refill.
4034
4150
  */
4035
- async acquire(provider, estimatedTokens) {
4151
+ async acquire(provider, estimatedTokens, signal) {
4036
4152
  const bucket = this.buckets.get(provider);
4037
4153
  if (!bucket || bucket.tokensPerMinute === Number.POSITIVE_INFINITY) return;
4154
+ if (signal?.aborted) throw abortReason(signal);
4038
4155
  const want = Math.min(estimatedTokens, bucket.tokensPerMinute);
4039
4156
  for (; ; ) {
4040
4157
  this.refill(bucket);
@@ -4044,7 +4161,8 @@ var TpmLimiter = class {
4044
4161
  }
4045
4162
  const deficit = want - bucket.available;
4046
4163
  const waitMs = Math.max(50, Math.ceil(deficit / bucket.tokensPerMinute * 6e4));
4047
- await new Promise((resolve2) => setTimeout(resolve2, waitMs));
4164
+ await sleepUnlessAborted(waitMs, signal);
4165
+ if (signal?.aborted) throw abortReason(signal);
4048
4166
  }
4049
4167
  }
4050
4168
  /**
@@ -4090,6 +4208,9 @@ var TpmLimiter = class {
4090
4208
  };
4091
4209
 
4092
4210
  // src/core/router/local-queue.ts
4211
+ function abortReason2(signal) {
4212
+ return signal.reason instanceof Error ? signal.reason : new Error("Aborted while waiting for a local inference slot");
4213
+ }
4093
4214
  var LocalRequestQueue = class {
4094
4215
  maxConcurrent;
4095
4216
  active = 0;
@@ -4100,9 +4221,16 @@ var LocalRequestQueue = class {
4100
4221
  /**
4101
4222
  * Acquire a queue slot. Returns a `release` function that MUST be called
4102
4223
  * when the inference call is done (even on error). Rejects if the slot
4103
- * cannot be acquired within `timeoutMs`.
4224
+ * cannot be acquired within `timeoutMs`, or as soon as `signal` fires.
4225
+ *
4226
+ * The signal matters as much as the timeout: with concurrency of 1 a caller
4227
+ * can sit here for half the inference timeout — 150 seconds by default —
4228
+ * and until it was wired up, neither cancelling the run nor spending its
4229
+ * budget got it out. It waited out the window for a slot it would drop the
4230
+ * moment it received one.
4104
4231
  */
4105
- async acquire(timeoutMs) {
4232
+ async acquire(timeoutMs, signal) {
4233
+ if (signal?.aborted) throw abortReason2(signal);
4106
4234
  if (this.active < this.maxConcurrent) {
4107
4235
  this.active++;
4108
4236
  return this.makeRelease();
@@ -4110,23 +4238,36 @@ var LocalRequestQueue = class {
4110
4238
  return new Promise((resolve2, reject) => {
4111
4239
  let settled = false;
4112
4240
  let timer;
4241
+ const cleanup = () => {
4242
+ if (timer !== void 0) clearTimeout(timer);
4243
+ signal?.removeEventListener("abort", onAbort);
4244
+ };
4113
4245
  const resolver = (release) => {
4114
4246
  if (settled) return;
4115
4247
  settled = true;
4116
- if (timer !== void 0) clearTimeout(timer);
4248
+ cleanup();
4117
4249
  resolve2(release);
4118
4250
  };
4251
+ const abandon = () => {
4252
+ if (settled) return false;
4253
+ settled = true;
4254
+ cleanup();
4255
+ const idx = this.queue.indexOf(resolver);
4256
+ if (idx !== -1) this.queue.splice(idx, 1);
4257
+ return true;
4258
+ };
4259
+ function onAbort() {
4260
+ if (abandon()) reject(abortReason2(signal));
4261
+ }
4119
4262
  if (timeoutMs !== void 0 && timeoutMs > 0) {
4120
4263
  timer = setTimeout(() => {
4121
- if (settled) return;
4122
- settled = true;
4123
- const idx = this.queue.indexOf(resolver);
4124
- if (idx !== -1) this.queue.splice(idx, 1);
4264
+ if (!abandon()) return;
4125
4265
  reject(new Error(
4126
4266
  `Local model queue: timed out waiting for a free slot after ${timeoutMs}ms. Active: ${this.active}, Queued: ${this.queue.length}. Consider increasing localConcurrency or localInferenceTimeoutMs in your config.`
4127
4267
  ));
4128
4268
  }, timeoutMs);
4129
4269
  }
4270
+ signal?.addEventListener("abort", onAbort, { once: true });
4130
4271
  this.queue.push(resolver);
4131
4272
  });
4132
4273
  }
@@ -4263,6 +4404,116 @@ function fileDeadModelPersistence(filePath) {
4263
4404
  };
4264
4405
  }
4265
4406
 
4407
+ // src/core/context/compaction.ts
4408
+ var CHARS_PER_TOKEN = 4;
4409
+ function estimateTokens(text) {
4410
+ if (!text) return 0;
4411
+ return Math.max(1, Math.ceil(text.length / CHARS_PER_TOKEN));
4412
+ }
4413
+ function contentToText(content) {
4414
+ if (typeof content === "string") return content;
4415
+ return content.map((c) => c.type === "text" ? c.text : c.type === "tool_result" ? c.content : `[${c.type}]`).join(" ");
4416
+ }
4417
+ function messagesTokens(messages) {
4418
+ return messages.reduce((n, m) => n + estimateTokens(contentToText(m.content)), 0);
4419
+ }
4420
+ function atomize(text, maxChars) {
4421
+ const seps = ["\n\n", "\n", ". ", " "];
4422
+ let units = [text];
4423
+ for (const sep of seps) {
4424
+ if (units.every((u) => u.length <= maxChars)) break;
4425
+ const next = [];
4426
+ for (const u of units) {
4427
+ if (u.length <= maxChars) {
4428
+ next.push(u);
4429
+ continue;
4430
+ }
4431
+ const parts = u.split(sep);
4432
+ parts.forEach((p, i) => next.push(i < parts.length - 1 ? p + sep : p));
4433
+ }
4434
+ units = next;
4435
+ }
4436
+ const out = [];
4437
+ for (const u of units) {
4438
+ if (u.length <= maxChars) out.push(u);
4439
+ else for (let i = 0; i < u.length; i += maxChars) out.push(u.slice(i, i + maxChars));
4440
+ }
4441
+ return out;
4442
+ }
4443
+ function chunkText(text, opts) {
4444
+ const maxChars = Math.max(1, Math.floor(opts.maxTokens * 4));
4445
+ const overlap = Math.min(maxChars - 1, Math.max(0, Math.floor(maxChars * (opts.overlapRatio ?? 0.1))));
4446
+ const trimmed = text.trim();
4447
+ if (!trimmed) return [];
4448
+ if (trimmed.length <= maxChars) return [trimmed];
4449
+ const units = atomize(trimmed, maxChars);
4450
+ const chunks = [];
4451
+ let cur = "";
4452
+ for (const u of units) {
4453
+ if (cur && cur.length + u.length > maxChars) {
4454
+ chunks.push(cur.trim());
4455
+ cur = overlap > 0 ? cur.slice(-overlap) : "";
4456
+ }
4457
+ cur += u;
4458
+ }
4459
+ if (cur.trim()) chunks.push(cur.trim());
4460
+ return chunks;
4461
+ }
4462
+ function needsCompaction(totalTokens, contextWindow, reserve = 0.2) {
4463
+ if (!contextWindow || contextWindow <= 0) return false;
4464
+ return totalTokens > contextWindow * (1 - reserve);
4465
+ }
4466
+ async function mapReduceCompact(text, opts) {
4467
+ const capChars = opts.capTokens * 4;
4468
+ let working = text;
4469
+ let truncated = false;
4470
+ if (working.length > capChars) {
4471
+ working = working.slice(0, capChars);
4472
+ truncated = true;
4473
+ }
4474
+ const instruction = opts.instruction ?? "Summarize this content, preserving key facts, names, numbers, and decisions.";
4475
+ let chunks = chunkText(working, { maxTokens: opts.chunkTokens });
4476
+ if (chunks.length <= 1) return { text: working, chunks: chunks.length, calls: 0, truncated };
4477
+ let calls = 0;
4478
+ let parts = await Promise.all(
4479
+ chunks.map((c, i) => {
4480
+ calls++;
4481
+ return opts.summarize(c, `${instruction} (part ${i + 1} of ${chunks.length})`);
4482
+ })
4483
+ );
4484
+ let combined = parts.join("\n\n");
4485
+ const maxPasses = opts.maxReducePasses ?? 3;
4486
+ for (let pass = 0; pass < maxPasses && estimateTokens(combined) > opts.targetTokens; pass++) {
4487
+ const groups = chunkText(combined, { maxTokens: opts.chunkTokens });
4488
+ if (groups.length <= 1) break;
4489
+ parts = await Promise.all(
4490
+ groups.map((g) => {
4491
+ calls++;
4492
+ return opts.summarize(g, "Merge these partial summaries into one cohesive summary; keep all distinct facts.");
4493
+ })
4494
+ );
4495
+ combined = parts.join("\n\n");
4496
+ }
4497
+ const note = truncated ? "\n\n[Note: input exceeded the extended-context cap and was truncated.]" : "";
4498
+ return { text: combined + note, chunks: chunks.length, calls, truncated };
4499
+ }
4500
+ async function rollingSummary(messages, opts) {
4501
+ if (messages.length <= opts.keepRecent) return messages;
4502
+ const older = messages.slice(0, messages.length - opts.keepRecent);
4503
+ const recent = messages.slice(messages.length - opts.keepRecent);
4504
+ const transcript = older.map((m) => `${m.role}: ${contentToText(m.content)}`).join("\n");
4505
+ const summary = await opts.summarize(
4506
+ transcript,
4507
+ "Summarize this earlier conversation. Preserve facts, decisions, names, numbers, and any open threads or todos."
4508
+ );
4509
+ const summaryMessage = {
4510
+ role: "system",
4511
+ content: `Summary of earlier conversation (folded to fit the context window):
4512
+ ${summary}`
4513
+ };
4514
+ return [summaryMessage, ...recent];
4515
+ }
4516
+
4266
4517
  // src/utils/retry.ts
4267
4518
  var CascadeCancelledError = class extends Error {
4268
4519
  constructor(reason) {
@@ -4297,6 +4548,158 @@ async function withTimeout(promise, timeoutMs, errorMessage = "Operation timed o
4297
4548
  if (timer !== void 0) clearTimeout(timer);
4298
4549
  }
4299
4550
  }
4551
+ async function withTimeoutAbort(run, timeoutMs, errorMessage = "Operation timed out", outer) {
4552
+ const outers = (Array.isArray(outer) ? outer : [outer]).filter((sig) => sig !== void 0);
4553
+ const controller = new AbortController();
4554
+ let timer;
4555
+ let rejectRace;
4556
+ const settle = (reason) => {
4557
+ rejectRace?.(reason);
4558
+ controller.abort(reason);
4559
+ };
4560
+ const abortOuter = () => {
4561
+ const fired = outers.find((sig) => sig.aborted);
4562
+ settle(fired?.reason instanceof Error ? fired.reason : new CascadeCancelledError("Run cancelled"));
4563
+ };
4564
+ const failPromise = new Promise((_, reject) => {
4565
+ rejectRace = reject;
4566
+ timer = setTimeout(() => settle(new Error(errorMessage)), timeoutMs);
4567
+ });
4568
+ try {
4569
+ if (outers.some((sig) => sig.aborted)) {
4570
+ abortOuter();
4571
+ return await failPromise;
4572
+ }
4573
+ for (const sig of outers) sig.addEventListener("abort", abortOuter, { once: true });
4574
+ return await Promise.race([run(controller.signal), failPromise]);
4575
+ } finally {
4576
+ if (timer !== void 0) clearTimeout(timer);
4577
+ for (const sig of outers) sig.removeEventListener("abort", abortOuter);
4578
+ }
4579
+ }
4580
+ function anySignal(sources) {
4581
+ const live = sources.filter((s) => s !== void 0);
4582
+ if (live.length === 0) return { release: () => {
4583
+ } };
4584
+ if (live.length === 1) return { signal: live[0], release: () => {
4585
+ } };
4586
+ const controller = new AbortController();
4587
+ function detach() {
4588
+ for (const s of live) s.removeEventListener("abort", onAbort);
4589
+ }
4590
+ function onAbort() {
4591
+ const fired = live.find((s) => s.aborted);
4592
+ controller.abort(fired?.reason instanceof Error ? fired.reason : new CascadeCancelledError("Run cancelled"));
4593
+ detach();
4594
+ }
4595
+ if (live.some((s) => s.aborted)) {
4596
+ onAbort();
4597
+ return { signal: controller.signal, release: detach };
4598
+ }
4599
+ for (const s of live) s.addEventListener("abort", onAbort, { once: true });
4600
+ return { signal: controller.signal, release: detach };
4601
+ }
4602
+
4603
+ // src/core/router/wire-profile.ts
4604
+ var textAndImageOnly = (block) => block.type === "text" || block.type === "image";
4605
+ var ANTHROPIC = {
4606
+ // convertMessages: `if (m.role === 'system') continue` — history system
4607
+ // messages are skipped outright; only options.systemPrompt goes as system
4608
+ // input. Compaction emits exactly such a message, holding a summary of the
4609
+ // whole conversation, so charging it refused runs over input never sent.
4610
+ dropsMessage: (m) => m.role === "system",
4611
+ blockHandling: (m) => {
4612
+ if (m.role === "tool") return "stringified";
4613
+ if (m.role === "assistant") return "dropped";
4614
+ return "blocks";
4615
+ },
4616
+ sendsToolCalls: (m) => m.role === "assistant",
4617
+ sendsBlock: textAndImageOnly,
4618
+ sendsToolCallId: true,
4619
+ sizeToolCalls: toAnthropicToolUse,
4620
+ sendsUrlImages: true,
4621
+ readsTopLevelImages: false,
4622
+ sizeTools: toAnthropicTools
4623
+ };
4624
+ var GEMINI = {
4625
+ // buildContents folds a system message into the next user turn, but only
4626
+ // when it has text: array content collapses to '' and the message is
4627
+ // skipped, as is a whitespace-only string.
4628
+ dropsMessage: (m) => m.role === "system" && !(typeof m.content === "string" ? m.content : "").trim(),
4629
+ blockHandling: (m) => {
4630
+ if (m.role === "tool") return "stringified";
4631
+ if (m.role === "assistant" || m.role === "system") return "dropped";
4632
+ return "blocks";
4633
+ },
4634
+ sendsToolCalls: (m) => m.role === "assistant",
4635
+ sendsBlock: textAndImageOnly,
4636
+ sendsToolCallId: true,
4637
+ sizeToolCalls: toGeminiFunctionCalls,
4638
+ sendsUrlImages: false,
4639
+ readsTopLevelImages: true,
4640
+ sizeTools: toGeminiTools
4641
+ };
4642
+ var OPENAI = {
4643
+ dropsMessage: () => false,
4644
+ blockHandling: (m) => {
4645
+ if (m.role === "system" || m.role === "tool") return "dropped";
4646
+ return "blocks";
4647
+ },
4648
+ // The tool_calls branch is inside `if (typeof m.content === 'string')`; an
4649
+ // assistant turn with array content falls through to the parts branch and
4650
+ // its tool calls are never attached.
4651
+ sendsToolCalls: (m) => m.role === "assistant" && typeof m.content === "string",
4652
+ sendsBlock: textAndImageOnly,
4653
+ sendsToolCallId: true,
4654
+ sizeToolCalls: toOpenAIToolCalls,
4655
+ sendsUrlImages: true,
4656
+ readsTopLevelImages: false,
4657
+ sizeTools: toOpenAITools
4658
+ };
4659
+ var OLLAMA = {
4660
+ dropsMessage: () => false,
4661
+ blockHandling: (m) => {
4662
+ if (m.role === "system") return "dropped";
4663
+ if (m.role === "tool") return "stringified";
4664
+ if (m.role === "assistant" && m.toolCalls?.length) return "dropped";
4665
+ return "blocks";
4666
+ },
4667
+ sendsToolCalls: (m) => m.role === "assistant",
4668
+ // The image split maps `b.image.data` with no check on `type`, so a URL is
4669
+ // submitted too (as a URL string in the `images` array).
4670
+ sendsBlock: textAndImageOnly,
4671
+ sendsToolCallId: false,
4672
+ sizeToolCalls: toOllamaToolCalls,
4673
+ sendsUrlImages: true,
4674
+ readsTopLevelImages: false,
4675
+ sizeTools: toOllamaTools
4676
+ };
4677
+ var PROFILES = {
4678
+ anthropic: ANTHROPIC,
4679
+ gemini: GEMINI,
4680
+ openai: OPENAI,
4681
+ // AzureOpenAIProvider and OpenAICompatibleProvider both extend OpenAIProvider
4682
+ // and neither overrides convertMessages.
4683
+ azure: OPENAI,
4684
+ "openai-compatible": OPENAI,
4685
+ ollama: OLLAMA
4686
+ };
4687
+ function wireProfile(provider) {
4688
+ return PROFILES[provider] ?? OPENAI;
4689
+ }
4690
+ function geminiImageCopies(messages) {
4691
+ for (const m of messages) {
4692
+ if (m.role === "user") {
4693
+ const laterUserTurn = messages.slice(messages.indexOf(m) + 1).some((x) => x.role === "user");
4694
+ return laterUserTurn ? 2 : 1;
4695
+ }
4696
+ const text = typeof m.content === "string" ? m.content : "";
4697
+ if (m.role === "system" && text.trim()) return 1;
4698
+ if (m.role === "tool") return 1;
4699
+ if (m.role === "assistant" && (text || m.toolCalls?.length)) return 1;
4700
+ }
4701
+ return 1;
4702
+ }
4300
4703
 
4301
4704
  // src/core/router/model-profiler.ts
4302
4705
  var SKIP_PATTERN = /embed|dall-e|whisper|tts|vision|instruct-vision|rerank/i;
@@ -4801,6 +5204,36 @@ function azureModelForTier(tier, ranked) {
4801
5204
  if (tier === "T3") return ranked[ranked.length - 1];
4802
5205
  return ranked[Math.min(1, ranked.length - 1)];
4803
5206
  }
5207
+ var IMAGE_TOKENS_MAX = 2e3;
5208
+ var IMAGE_TOKENS_EACH = IMAGE_TOKENS_MAX;
5209
+ function safeJson(value) {
5210
+ try {
5211
+ return JSON.stringify(value) ?? "";
5212
+ } catch {
5213
+ return "";
5214
+ }
5215
+ }
5216
+ function newRunAbort() {
5217
+ const controller = new AbortController();
5218
+ EventEmitter.setMaxListeners(64, controller.signal);
5219
+ return controller;
5220
+ }
5221
+ var TOKENS_PER_MESSAGE_FRAMING = 4;
5222
+ function guardTokens(text) {
5223
+ if (!text) return 0;
5224
+ let asciiChars = 0;
5225
+ let denseBytes = 0;
5226
+ for (const ch of text) {
5227
+ const cp = ch.codePointAt(0);
5228
+ if (cp <= 127) {
5229
+ asciiChars++;
5230
+ continue;
5231
+ }
5232
+ denseBytes += cp <= 2047 ? 2 : cp <= 65535 ? 3 : 4;
5233
+ }
5234
+ if (denseBytes === 0) return estimateTokens(text);
5235
+ return Math.ceil(asciiChars / CHARS_PER_TOKEN) + denseBytes;
5236
+ }
4804
5237
  var DISCOVERY_TTL_MS = 15 * 60 * 1e3;
4805
5238
  var DISCOVERY_TIMEOUT_MS = 4e3;
4806
5239
  var cloudDiscoveryCache = /* @__PURE__ */ new Map();
@@ -4853,6 +5286,33 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
4853
5286
  // start of every `cascade run`, independent of the session-wide budget.
4854
5287
  runTokens = 0;
4855
5288
  runCostUsd = 0;
5289
+ /**
5290
+ * Budget held for calls that are IN FLIGHT — admitted by the preflight check
5291
+ * but not yet counted by recordStats. Without these, concurrent calls all
5292
+ * measure themselves against the same unspent allowance and are all admitted.
5293
+ * Released when each call settles; reset by beginRun() alongside the totals.
5294
+ */
5295
+ reservedTokens = 0;
5296
+ reservedCostUsd = 0;
5297
+ /**
5298
+ * Fires when this run's budget is spent, cancelling work already in flight.
5299
+ *
5300
+ * The kill switch was previously only a flag, which stops calls that have
5301
+ * not yet submitted. A parallel wave's earlier members are already at the
5302
+ * provider by then, and they carried on generating for a run whose output
5303
+ * would be discarded. Every provider call chains to this, so tripping the
5304
+ * ceiling reaches the requests that are running, not just the ones queued.
5305
+ */
5306
+ runAbort = newRunAbort();
5307
+ /**
5308
+ * Bumped by every beginRun(). A release handle remembers the generation it
5309
+ * was issued in and does nothing if that has moved on: beginRun() zeroes the
5310
+ * counters, so a call still in flight from the previous run would otherwise
5311
+ * subtract its estimate from the NEW run's totals and drive them negative —
5312
+ * leaving later preflights believing there is more allowance than the cap
5313
+ * actually holds, which is worse than no gate at all.
5314
+ */
5315
+ runGeneration = 0;
4856
5316
  runBudgetExceeded = false;
4857
5317
  runBudgetExceededReason;
4858
5318
  /**
@@ -5022,7 +5482,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5022
5482
  try {
5023
5483
  const cfg = this.config.providers.find((p) => p.type === model.provider) ?? { type: model.provider };
5024
5484
  const provider = this.createProvider(cfg, model);
5025
- const result = await withTimeout(provider.generate({
5485
+ const result = await withTimeoutAbort((signal) => provider.generate({
5026
5486
  messages: [{ role: "user", content: "Call the echo tool with text set to 'ping'. Use the tool; do not answer in prose." }],
5027
5487
  tools: [{
5028
5488
  name: "echo",
@@ -5030,7 +5490,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5030
5490
  inputSchema: { type: "object", properties: { text: { type: "string", description: "Text to echo" } }, required: ["text"] }
5031
5491
  }],
5032
5492
  maxTokens: 80,
5033
- temperature: 0
5493
+ temperature: 0,
5494
+ signal
5034
5495
  }), 3e4, "tool-support probe timed out");
5035
5496
  return (result.toolCalls?.length ?? 0) > 0;
5036
5497
  } catch {
@@ -5151,9 +5612,12 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5151
5612
  this.runBudgetExceededReason ?? "Per-task budget exceeded."
5152
5613
  );
5153
5614
  }
5615
+ const runGeneration = this.runGeneration;
5616
+ const runAbort = this.runAbort;
5617
+ const runSignal = this.runSignal;
5154
5618
  options = applyTierLimits(options, tier, this.config?.tierLimits);
5155
- if (this.runSignal && !options.signal) {
5156
- options = { ...options, signal: this.runSignal };
5619
+ if (runSignal && !options.signal) {
5620
+ options = { ...options, signal: runSignal };
5157
5621
  }
5158
5622
  if (options.model && !requireVision) {
5159
5623
  this.ensureProvider(options.model, this.config.providers);
@@ -5175,58 +5639,84 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5175
5639
  if (!model) throw new Error(`No model available for tier ${tier}`);
5176
5640
  const provider = this.getProvider(model);
5177
5641
  if (!provider) throw new Error(`No provider for model ${model.id}`);
5642
+ let releaseReservation = this.enforcePreflightBudget(model, options);
5178
5643
  const requestedTokens = options.maxTokens ?? model.maxOutputTokens ?? 1024;
5179
5644
  const estimatedTokens = Math.min(requestedTokens, model.maxOutputTokens ?? requestedTokens) + 512;
5180
5645
  if (this.tpmLimiter) {
5181
- await this.tpmLimiter.acquire(model.provider, estimatedTokens);
5646
+ const wait = anySignal([options.signal, runAbort.signal]);
5647
+ try {
5648
+ await this.tpmLimiter.acquire(model.provider, estimatedTokens, wait.signal);
5649
+ } catch (err) {
5650
+ releaseReservation?.();
5651
+ throw this.asAbortFailure(err, options.signal, runSignal);
5652
+ } finally {
5653
+ wait.release();
5654
+ }
5182
5655
  }
5183
5656
  const useStream = Boolean(onChunk) && model.supportsStreaming && typeof provider.generateStream === "function";
5184
5657
  let releaseLocalSlot;
5185
5658
  if (model.isLocal) {
5186
5659
  const inferenceTimeoutMs = this.config.localInferenceTimeoutMs ?? 3e5;
5187
5660
  const queueWaitMs = Math.round(inferenceTimeoutMs / 2);
5188
- releaseLocalSlot = await this.localQueue.acquire(queueWaitMs);
5661
+ const wait = anySignal([options.signal, runAbort.signal]);
5662
+ try {
5663
+ releaseLocalSlot = await this.localQueue.acquire(queueWaitMs, wait.signal);
5664
+ } catch (err) {
5665
+ releaseReservation?.();
5666
+ throw this.asAbortFailure(err, options.signal, runSignal);
5667
+ } finally {
5668
+ wait.release();
5669
+ }
5189
5670
  }
5190
5671
  try {
5672
+ if (runGeneration === this.runGeneration && this.runBudgetExceeded) {
5673
+ this.tpmLimiter?.refund(model.provider, estimatedTokens);
5674
+ throw new _CascadeRouter.BudgetExceededError(
5675
+ this.runBudgetExceededReason ?? "Per-task budget exceeded."
5676
+ );
5677
+ }
5191
5678
  let result;
5192
5679
  if (model.isLocal) {
5193
5680
  const inferenceTimeoutMs = this.config.localInferenceTimeoutMs ?? 3e5;
5194
- const inferencePromise = useStream && onChunk ? provider.generateStream(options, (chunk) => {
5195
- const text = typeof chunk?.text === "string" ? chunk.text : "";
5196
- if (text) onChunk({ ...chunk, text });
5197
- }) : provider.generate(options);
5198
- result = await withTimeout(
5199
- inferencePromise,
5681
+ result = await withTimeoutAbort(
5682
+ (signal) => useStream && onChunk ? provider.generateStream({ ...options, signal }, (chunk) => {
5683
+ const text = typeof chunk?.text === "string" ? chunk.text : "";
5684
+ if (text) onChunk({ ...chunk, text });
5685
+ }) : provider.generate({ ...options, signal }),
5200
5686
  inferenceTimeoutMs,
5201
- `Local model ${model.id} inference timed out after ${inferenceTimeoutMs}ms`
5687
+ `Local model ${model.id} inference timed out after ${inferenceTimeoutMs}ms`,
5688
+ [options.signal, runAbort.signal]
5202
5689
  );
5203
5690
  } else if (useStream && onChunk) {
5204
5691
  const cloudTimeoutMs = this.config.cloudInferenceTimeoutMs ?? 12e4;
5205
5692
  try {
5206
- result = await withTimeout(
5207
- provider.generateStream(options, (chunk) => {
5693
+ result = await withTimeoutAbort(
5694
+ (signal) => provider.generateStream({ ...options, signal }, (chunk) => {
5208
5695
  const text = typeof chunk?.text === "string" ? chunk.text : "";
5209
5696
  if (text) onChunk({ ...chunk, text });
5210
5697
  }),
5211
5698
  cloudTimeoutMs,
5212
- `Model ${model.id} stream timed out after ${cloudTimeoutMs}ms`
5699
+ `Model ${model.id} stream timed out after ${cloudTimeoutMs}ms`,
5700
+ [options.signal, runAbort.signal]
5213
5701
  );
5214
5702
  } catch (streamErr) {
5215
- if (streamErr instanceof Error && streamErr.name === "AbortError" || this.runSignal?.aborted || options.signal?.aborted) {
5703
+ if (streamErr instanceof Error && streamErr.name === "AbortError" || runSignal?.aborted || options.signal?.aborted) {
5216
5704
  throw streamErr;
5217
5705
  }
5218
- result = await withTimeout(
5219
- provider.generate(options),
5706
+ result = await withTimeoutAbort(
5707
+ (signal) => provider.generate({ ...options, signal }),
5220
5708
  cloudTimeoutMs,
5221
- `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`
5709
+ `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`,
5710
+ [options.signal, runAbort.signal]
5222
5711
  );
5223
5712
  }
5224
5713
  } else {
5225
5714
  const cloudTimeoutMs = this.config.cloudInferenceTimeoutMs ?? 12e4;
5226
- result = await withTimeout(
5227
- provider.generate(options),
5715
+ result = await withTimeoutAbort(
5716
+ (signal) => provider.generate({ ...options, signal }),
5228
5717
  cloudTimeoutMs,
5229
- `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`
5718
+ `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`,
5719
+ [options.signal, runAbort.signal]
5230
5720
  );
5231
5721
  }
5232
5722
  const corrected = buildTokenUsage(
@@ -5245,11 +5735,12 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5245
5735
  if (!result || typeof result.content !== "string" || !result.usage) {
5246
5736
  throw new Error(`Provider ${model.provider}:${model.id} returned an invalid generation result.`);
5247
5737
  }
5248
- this.recordStats(tier, model, result.usage, options.featureTag);
5738
+ this.recordStats(tier, model, result.usage, options.featureTag, runGeneration);
5249
5739
  this.failover.recordSuccess(model.provider);
5250
5740
  return result;
5251
5741
  } catch (err) {
5252
- if (err instanceof Error && err.name === "AbortError" || this.runSignal?.aborted || options.signal?.aborted) {
5742
+ if (err instanceof _CascadeRouter.BudgetExceededError) throw err;
5743
+ if (err instanceof Error && err.name === "AbortError" || runSignal?.aborted || options.signal?.aborted) {
5253
5744
  throw new CascadeCancelledError("Run cancelled");
5254
5745
  }
5255
5746
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -5267,6 +5758,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5267
5758
  });
5268
5759
  releaseLocalSlot?.();
5269
5760
  releaseLocalSlot = void 0;
5761
+ releaseReservation?.();
5762
+ releaseReservation = void 0;
5270
5763
  const retryOpts = options.model && options.model.id === model.id ? { ...options, model: void 0 } : options;
5271
5764
  return this.generate(tier, retryOpts, onChunk, requireVision);
5272
5765
  }
@@ -5288,6 +5781,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5288
5781
  });
5289
5782
  releaseLocalSlot?.();
5290
5783
  releaseLocalSlot = void 0;
5784
+ releaseReservation?.();
5785
+ releaseReservation = void 0;
5291
5786
  const base = options.model && options.model.id === model.id ? { ...options, model: void 0 } : options;
5292
5787
  const retryOpts = { ...base, _notFoundDepth: depth };
5293
5788
  return this.generate(tier, retryOpts, onChunk, requireVision);
@@ -5296,6 +5791,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5296
5791
  throw err;
5297
5792
  } finally {
5298
5793
  releaseLocalSlot?.();
5794
+ releaseReservation?.();
5795
+ releaseReservation = void 0;
5299
5796
  }
5300
5797
  }
5301
5798
  getModelForTier(tier) {
@@ -5558,12 +6055,28 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5558
6055
  return this.selector?.getAllAvailableModels() ?? [];
5559
6056
  }
5560
6057
  // ── Private ──────────────────────────────────
6058
+ /**
6059
+ * Why each configured provider failed its availability probe, kept so the
6060
+ * surfaces can say something better than "add an API key" to a user who
6061
+ * already has one. Cleared and refilled by each detectAvailableProviders().
6062
+ */
6063
+ probeFailures = /* @__PURE__ */ new Map();
6064
+ /**
6065
+ * What stopped the configured providers from being usable, if anything.
6066
+ * Empty when every configured provider passed — or when none was configured,
6067
+ * which is a different problem with a different answer.
6068
+ */
6069
+ providerProbeFailures() {
6070
+ return [...this.probeFailures].map(([provider, reason]) => ({ provider, reason }));
6071
+ }
5561
6072
  /** Logs why a configured provider failed its availability probe. */
5562
6073
  emitProbeFailure(type, reason) {
6074
+ this.probeFailures.set(type, reason);
5563
6075
  console.warn(`[router] provider "${type}" is not available: ${reason}`);
5564
6076
  }
5565
6077
  async detectAvailableProviders(configs) {
5566
6078
  const available = /* @__PURE__ */ new Set();
6079
+ this.probeFailures.clear();
5567
6080
  const checks = configs.map(async (cfg) => {
5568
6081
  try {
5569
6082
  const testModel = this.getAnyModelForProvider(cfg.type);
@@ -5573,7 +6086,13 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5573
6086
  if (ok) available.add(cfg.type);
5574
6087
  else this.emitProbeFailure(cfg.type, "availability check returned false (bad key, wrong endpoint/deployment, or unreachable)");
5575
6088
  } catch (err) {
5576
- this.emitProbeFailure(cfg.type, err instanceof Error ? err.message : String(err));
6089
+ const reason = err instanceof Error ? err.message : String(err);
6090
+ if (err instanceof ProviderUnreachableError) {
6091
+ available.add(cfg.type);
6092
+ console.warn(`[router] provider "${cfg.type}" probe did not complete: ${reason} \u2014 continuing with it enabled`);
6093
+ return;
6094
+ }
6095
+ this.emitProbeFailure(cfg.type, reason);
5577
6096
  }
5578
6097
  });
5579
6098
  await Promise.allSettled(checks);
@@ -5666,7 +6185,14 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5666
6185
  }
5667
6186
  return void 0;
5668
6187
  }
5669
- recordStats(tier, model, usage, featureTag) {
6188
+ /**
6189
+ * `runGeneration` is the generation the call was ADMITTED in — see
6190
+ * `generate()`. Session-wide accounting always applies (the spend is real
6191
+ * whenever it lands), but the per-RUN ceiling is only meaningful for the run
6192
+ * the call belongs to, so a straggler from a finished run is left out of it.
6193
+ * Omitted by the direct callers in tests, which have no run to straddle.
6194
+ */
6195
+ recordStats(tier, model, usage, featureTag, runGeneration) {
5670
6196
  this.stats.totalTokens += usage.totalTokens;
5671
6197
  this.stats.totalCostUsd += usage.estimatedCostUsd;
5672
6198
  this.sessionCostUsd += usage.estimatedCostUsd;
@@ -5691,10 +6217,13 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5691
6217
  });
5692
6218
  }
5693
6219
  }
5694
- this.runTokens += usage.totalTokens;
5695
- this.runCostUsd += usage.estimatedCostUsd;
6220
+ const currentRun = runGeneration === void 0 || runGeneration === this.runGeneration;
6221
+ if (currentRun) {
6222
+ this.runTokens += usage.totalTokens;
6223
+ this.runCostUsd += usage.estimatedCostUsd;
6224
+ }
5696
6225
  this.updateBudgetState();
5697
- this.enforceRunBudget();
6226
+ if (currentRun) this.enforceRunBudget();
5698
6227
  }
5699
6228
  /**
5700
6229
  * Resets per-run accounting at the start of each `cascade run`. Session
@@ -5704,9 +6233,158 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5704
6233
  beginRun() {
5705
6234
  this.runTokens = 0;
5706
6235
  this.runCostUsd = 0;
6236
+ this.reservedTokens = 0;
6237
+ this.reservedCostUsd = 0;
6238
+ this.runAbort = newRunAbort();
6239
+ this.runGeneration++;
5707
6240
  this.runBudgetExceeded = false;
5708
6241
  this.runBudgetExceededReason = void 0;
5709
6242
  }
6243
+ /**
6244
+ * Refuse a call whose input alone cannot fit what is left of the run budget.
6245
+ *
6246
+ * `enforceRunBudget()` below runs AFTER a call returns, which makes the caps
6247
+ * a stop rather than a pre-authorisation: the tokens are already bought by
6248
+ * the time it looks. That was tolerable while a prompt could not exceed
6249
+ * 20,000 characters, and stopped being so when that cap was removed in
6250
+ * 0.72.0 — a multi-megabyte prompt is billed in full on the very first call
6251
+ * (the complexity classifier sends the whole thing), and no ceiling
6252
+ * configured downstream can give that money back.
6253
+ *
6254
+ * Deliberately narrow, because a false refusal is worse than a late stop:
6255
+ *
6256
+ * • INPUT ONLY. What a call returns is not knowable in advance, and
6257
+ * charging a worst-case `maxTokens` of output against the budget would
6258
+ * decline runs that would have finished comfortably. Input is the part
6259
+ * that is already determined, and it is the part the removed cap used to
6260
+ * bound.
6261
+ * • Compared against what REMAINS (`cap - spent`), not the whole cap, so a
6262
+ * call is judged on the budget it can actually draw from.
6263
+ * • Skipped when the model has no usable price. An estimate cannot be made,
6264
+ * and refusing on ignorance would break every self-hosted and local model
6265
+ * the moment a cost cap is set. The post-hoc stop still applies there.
6266
+ * • Skipped entirely when no cap is configured — there is nothing to
6267
+ * pre-authorise against.
6268
+ *
6269
+ * The message names the cap AND the estimate, because "too expensive" with
6270
+ * no numbers gives the user nothing to change.
6271
+ */
6272
+ enforcePreflightBudget(model, options) {
6273
+ const budget = this.config?.budget;
6274
+ const maxCost = budget?.maxCostPerRunUsd;
6275
+ const maxTokens = budget?.maxTokensPerRun;
6276
+ if (maxCost == null && maxTokens == null) return void 0;
6277
+ const inputTokens = this.estimateInputTokens(options, model);
6278
+ if (maxTokens != null) {
6279
+ const remaining = maxTokens - this.runTokens - this.reservedTokens;
6280
+ if (inputTokens > remaining) {
6281
+ this.failPreflight(
6282
+ `This request's input is about ${inputTokens.toLocaleString()} tokens, but only ${Math.max(0, remaining).toLocaleString()} of the per-task cap of ${maxTokens.toLocaleString()} remain. Shorten the input, or raise budget.maxTokensPerRun.`
6283
+ );
6284
+ }
6285
+ }
6286
+ let estimatedUsd = 0;
6287
+ if (maxCost != null) {
6288
+ const { input: inputPer1k, unknown } = resolveModelPricing(model, { inputTokens });
6289
+ if (!unknown && inputPer1k > 0) {
6290
+ estimatedUsd = inputTokens / 1e3 * inputPer1k;
6291
+ const remaining = maxCost - this.runCostUsd - this.reservedCostUsd;
6292
+ if (estimatedUsd > remaining) {
6293
+ this.failPreflight(
6294
+ `Sending this request to ${model.provider}:${model.id} would cost about $${estimatedUsd.toFixed(4)} in input alone (~${inputTokens.toLocaleString()} tokens), and only $${Math.max(0, remaining).toFixed(4)} of the per-task cap of $${maxCost.toFixed(4)} remains. Shorten the input, choose a cheaper model, or raise budget.maxCostPerRunUsd.`
6295
+ );
6296
+ }
6297
+ }
6298
+ }
6299
+ this.reservedTokens += inputTokens;
6300
+ this.reservedCostUsd += estimatedUsd;
6301
+ const generation = this.runGeneration;
6302
+ let released = false;
6303
+ return () => {
6304
+ if (released || generation !== this.runGeneration) return;
6305
+ released = true;
6306
+ this.reservedTokens -= inputTokens;
6307
+ this.reservedCostUsd -= estimatedUsd;
6308
+ };
6309
+ }
6310
+ /**
6311
+ * What this call will send, in tokens.
6312
+ *
6313
+ * Text is the bulk of it, but not all of it: providers bill the serialized
6314
+ * TOOL DEFINITIONS on every native-tool call (Anthropic sends each name,
6315
+ * description and input schema), and an image is billed by its own size
6316
+ * rather than as the `[image]` placeholder `contentToText` reduces it to. A
6317
+ * tool-heavy or vision request could therefore pass either cap while its real
6318
+ * input already exceeded the allowance.
6319
+ */
6320
+ estimateInputTokens(options, model) {
6321
+ let tokens = guardTokens(options.systemPrompt ?? "");
6322
+ let images = 0;
6323
+ const wire = wireProfile(model.provider);
6324
+ const countsImage = (img) => wire.sendsUrlImages || img?.type === "base64";
6325
+ for (const message of options.messages) {
6326
+ if (wire.dropsMessage(message)) continue;
6327
+ tokens += TOKENS_PER_MESSAGE_FRAMING;
6328
+ if (typeof message.content === "string") {
6329
+ tokens += guardTokens(message.content);
6330
+ } else {
6331
+ switch (wire.blockHandling(message)) {
6332
+ case "blocks":
6333
+ for (const block of message.content) {
6334
+ if (!wire.sendsBlock(block)) continue;
6335
+ if (block.type === "image") {
6336
+ if (countsImage(block.image)) images++;
6337
+ continue;
6338
+ }
6339
+ tokens += guardTokens(contentToText([block]));
6340
+ }
6341
+ break;
6342
+ case "stringified":
6343
+ tokens += guardTokens(safeJson(message.content));
6344
+ break;
6345
+ }
6346
+ }
6347
+ if (message.toolCalls?.length && wire.sendsToolCalls(message)) {
6348
+ tokens += guardTokens(safeJson(wire.sizeToolCalls(message.toolCalls)));
6349
+ }
6350
+ if (message.toolCallId && message.role === "tool" && wire.sendsToolCallId) {
6351
+ tokens += guardTokens(message.toolCallId);
6352
+ }
6353
+ }
6354
+ if (options.tools?.length) {
6355
+ tokens += guardTokens(safeJson(wire.sizeTools(options.tools)));
6356
+ }
6357
+ if (wire.readsTopLevelImages) {
6358
+ const topLevel = (options.images ?? []).filter(countsImage).length;
6359
+ images += topLevel * geminiImageCopies(options.messages);
6360
+ }
6361
+ tokens += images * IMAGE_TOKENS_EACH;
6362
+ return tokens;
6363
+ }
6364
+ /**
6365
+ * Report a wait that ended because something asked it to stop, the same way
6366
+ * the provider call below reports one.
6367
+ *
6368
+ * The queue waits sit OUTSIDE the try/catch that maps aborts, so without
6369
+ * this a cancellation surfaced as whatever the queue happened to throw and
6370
+ * was retried or failed over like an ordinary error. A budget abort keeps
6371
+ * saying it ran out of budget — otherwise the reason the run stopped is lost
6372
+ * on the way up — and anything else that was aborted becomes a cancellation.
6373
+ */
6374
+ asAbortFailure(err, callSignal, runSignal) {
6375
+ if (err instanceof _CascadeRouter.BudgetExceededError) return err;
6376
+ if (err instanceof CascadeCancelledError) return err;
6377
+ if (callSignal?.aborted || runSignal?.aborted) return new CascadeCancelledError("Run cancelled");
6378
+ return err;
6379
+ }
6380
+ /** Trips the same run-level flag a post-hoc overrun does, and reports it the same way. */
6381
+ failPreflight(reason) {
6382
+ this.runBudgetExceeded = true;
6383
+ this.runBudgetExceededReason = reason;
6384
+ this.runAbort.abort(new _CascadeRouter.BudgetExceededError(reason));
6385
+ this.emit("budget:exceeded", { reason, spentUsd: this.sessionCostUsd });
6386
+ throw new _CascadeRouter.BudgetExceededError(reason);
6387
+ }
5710
6388
  /**
5711
6389
  * Enforce the hard per-task ceiling. Once tripped, the flag makes every
5712
6390
  * subsequent (and concurrent) generate() call in this run fail fast.
@@ -5722,6 +6400,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
5722
6400
  const reason = overTokens ? `Per-task token cap of ${maxTokens.toLocaleString()} reached (used ${this.runTokens.toLocaleString()}). Stopping this run to avoid runaway cost \u2014 raise budget.maxTokensPerRun for larger jobs.` : `Per-task cost cap of $${maxCost.toFixed(4)} reached (spent $${this.runCostUsd.toFixed(4)}). Stopping this run to avoid runaway cost.`;
5723
6401
  this.runBudgetExceeded = true;
5724
6402
  this.runBudgetExceededReason = reason;
6403
+ this.runAbort.abort(new _CascadeRouter.BudgetExceededError(reason));
5725
6404
  this.emit("budget:exceeded", { reason, spentUsd: this.sessionCostUsd });
5726
6405
  throw new _CascadeRouter.BudgetExceededError(reason);
5727
6406
  }
@@ -6958,6 +7637,12 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
6958
7637
  maxTokens: 4096,
6959
7638
  ...subtaskModel ? { model: subtaskModel } : {},
6960
7639
  featureTag: this.assignment?.sectionTitle,
7640
+ // The wave signal, so cancel-and-respawn reaches the request that is
7641
+ // RUNNING rather than only stopping the worker at its next checkpoint.
7642
+ // It is a superset of the run signal the router injects when this is
7643
+ // absent (T2 composes it from that signal plus its own per-wave one),
7644
+ // so wiring it here loses no cancellation and adds the wave's.
7645
+ ...this.signal ? { signal: this.signal } : {},
6961
7646
  ...this.localOnlyMatch ? { forceLocal: true } : {}
6962
7647
  };
6963
7648
  const result = await this.router.generate(
@@ -7436,6 +8121,7 @@ ${current}`
7436
8121
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7437
8122
  maxTokens: 4096,
7438
8123
  featureTag: assignment.sectionTitle,
8124
+ ...this.signal ? { signal: this.signal } : {},
7439
8125
  ...this.localOnlyMatch ? { forceLocal: true } : {}
7440
8126
  });
7441
8127
  const next = (improved.content ?? "").trim();
@@ -7497,6 +8183,7 @@ Reply with JSON: { "completeness": "pass"|"fail", "correctness": "pass"|"fail",
7497
8183
 
7498
8184
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7499
8185
  featureTag: assignment.sectionTitle,
8186
+ ...this.signal ? { signal: this.signal } : {},
7500
8187
  ...this.localOnlyMatch ? { forceLocal: true } : {}
7501
8188
  });
7502
8189
  try {
@@ -7536,6 +8223,7 @@ ${output.slice(0, 4e3)}`;
7536
8223
  messages: [{ role: "user", content: prompt }],
7537
8224
  maxTokens: 300,
7538
8225
  temperature: 0,
8226
+ ...this.signal ? { signal: this.signal } : {},
7539
8227
  ...this.localOnlyMatch ? { forceLocal: true } : {}
7540
8228
  });
7541
8229
  const match = /\[[\s\S]*\]/.exec(result.content);
@@ -7647,6 +8335,10 @@ Begin execution now.`;
7647
8335
  }
7648
8336
  };
7649
8337
  var PeerBus = class extends EventEmitter__default.default {
8338
+ constructor() {
8339
+ super();
8340
+ this.setMaxListeners(64);
8341
+ }
7650
8342
  outputs = /* @__PURE__ */ new Map();
7651
8343
  waiters = /* @__PURE__ */ new Map();
7652
8344
  members = /* @__PURE__ */ new Set();
@@ -8894,6 +9586,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
8894
9586
  const waveSignal = AbortSignal.any(
8895
9587
  [this.signal, this.waveAbortController.signal].filter(Boolean)
8896
9588
  );
9589
+ EventEmitter.setMaxListeners(64, waveSignal);
8897
9590
  const runOne = async (id) => {
8898
9591
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
8899
9592
  const worker = workerMap.get(id);
@@ -16537,115 +17230,6 @@ var GraphSearchTool = class extends BaseTool {
16537
17230
  }
16538
17231
  };
16539
17232
 
16540
- // src/core/context/compaction.ts
16541
- function estimateTokens(text) {
16542
- if (!text) return 0;
16543
- return Math.max(1, Math.ceil(text.length / 4));
16544
- }
16545
- function contentToText(content) {
16546
- if (typeof content === "string") return content;
16547
- return content.map((c) => c.type === "text" ? c.text : c.type === "tool_result" ? c.content : `[${c.type}]`).join(" ");
16548
- }
16549
- function messagesTokens(messages) {
16550
- return messages.reduce((n, m) => n + estimateTokens(contentToText(m.content)), 0);
16551
- }
16552
- function atomize(text, maxChars) {
16553
- const seps = ["\n\n", "\n", ". ", " "];
16554
- let units = [text];
16555
- for (const sep of seps) {
16556
- if (units.every((u) => u.length <= maxChars)) break;
16557
- const next = [];
16558
- for (const u of units) {
16559
- if (u.length <= maxChars) {
16560
- next.push(u);
16561
- continue;
16562
- }
16563
- const parts = u.split(sep);
16564
- parts.forEach((p, i) => next.push(i < parts.length - 1 ? p + sep : p));
16565
- }
16566
- units = next;
16567
- }
16568
- const out = [];
16569
- for (const u of units) {
16570
- if (u.length <= maxChars) out.push(u);
16571
- else for (let i = 0; i < u.length; i += maxChars) out.push(u.slice(i, i + maxChars));
16572
- }
16573
- return out;
16574
- }
16575
- function chunkText(text, opts) {
16576
- const maxChars = Math.max(1, Math.floor(opts.maxTokens * 4));
16577
- const overlap = Math.min(maxChars - 1, Math.max(0, Math.floor(maxChars * (opts.overlapRatio ?? 0.1))));
16578
- const trimmed = text.trim();
16579
- if (!trimmed) return [];
16580
- if (trimmed.length <= maxChars) return [trimmed];
16581
- const units = atomize(trimmed, maxChars);
16582
- const chunks = [];
16583
- let cur = "";
16584
- for (const u of units) {
16585
- if (cur && cur.length + u.length > maxChars) {
16586
- chunks.push(cur.trim());
16587
- cur = overlap > 0 ? cur.slice(-overlap) : "";
16588
- }
16589
- cur += u;
16590
- }
16591
- if (cur.trim()) chunks.push(cur.trim());
16592
- return chunks;
16593
- }
16594
- function needsCompaction(totalTokens, contextWindow, reserve = 0.2) {
16595
- if (!contextWindow || contextWindow <= 0) return false;
16596
- return totalTokens > contextWindow * (1 - reserve);
16597
- }
16598
- async function mapReduceCompact(text, opts) {
16599
- const capChars = opts.capTokens * 4;
16600
- let working = text;
16601
- let truncated = false;
16602
- if (working.length > capChars) {
16603
- working = working.slice(0, capChars);
16604
- truncated = true;
16605
- }
16606
- const instruction = opts.instruction ?? "Summarize this content, preserving key facts, names, numbers, and decisions.";
16607
- let chunks = chunkText(working, { maxTokens: opts.chunkTokens });
16608
- if (chunks.length <= 1) return { text: working, chunks: chunks.length, calls: 0, truncated };
16609
- let calls = 0;
16610
- let parts = await Promise.all(
16611
- chunks.map((c, i) => {
16612
- calls++;
16613
- return opts.summarize(c, `${instruction} (part ${i + 1} of ${chunks.length})`);
16614
- })
16615
- );
16616
- let combined = parts.join("\n\n");
16617
- const maxPasses = opts.maxReducePasses ?? 3;
16618
- for (let pass = 0; pass < maxPasses && estimateTokens(combined) > opts.targetTokens; pass++) {
16619
- const groups = chunkText(combined, { maxTokens: opts.chunkTokens });
16620
- if (groups.length <= 1) break;
16621
- parts = await Promise.all(
16622
- groups.map((g) => {
16623
- calls++;
16624
- return opts.summarize(g, "Merge these partial summaries into one cohesive summary; keep all distinct facts.");
16625
- })
16626
- );
16627
- combined = parts.join("\n\n");
16628
- }
16629
- const note = truncated ? "\n\n[Note: input exceeded the extended-context cap and was truncated.]" : "";
16630
- return { text: combined + note, chunks: chunks.length, calls, truncated };
16631
- }
16632
- async function rollingSummary(messages, opts) {
16633
- if (messages.length <= opts.keepRecent) return messages;
16634
- const older = messages.slice(0, messages.length - opts.keepRecent);
16635
- const recent = messages.slice(messages.length - opts.keepRecent);
16636
- const transcript = older.map((m) => `${m.role}: ${contentToText(m.content)}`).join("\n");
16637
- const summary = await opts.summarize(
16638
- transcript,
16639
- "Summarize this earlier conversation. Preserve facts, decisions, names, numbers, and any open threads or todos."
16640
- );
16641
- const summaryMessage = {
16642
- role: "system",
16643
- content: `Summary of earlier conversation (folded to fit the context window):
16644
- ${summary}`
16645
- };
16646
- return [summaryMessage, ...recent];
16647
- }
16648
-
16649
17233
  // src/core/steering/guidance.ts
16650
17234
  var GuidanceQueue = class {
16651
17235
  entries = [];
@@ -18197,7 +18781,16 @@ ${finalOutput}` : ""
18197
18781
  const selector = this.router.getSelector();
18198
18782
  const model = options.fastAnswerModel ? selector.selectForTier(tier, options.fastAnswerModel) : selector.getCandidatesForTier(tier)[0] ?? selector.selectForTier(tier);
18199
18783
  if (!model) {
18200
- throw new Error("No model is available for a fast answer \u2014 add a provider API key first.");
18784
+ const failures2 = this.router.providerProbeFailures();
18785
+ const context = failures2.length ? ` Configured providers: ${failures2.map((f) => `${f.provider}: ${f.reason}`).join("; ")}` : "";
18786
+ if (options.fastAnswerModel) {
18787
+ throw new Error(
18788
+ `No model is available for a fast answer: the pinned model "${options.fastAnswerModel}" could not be resolved \u2014 check it is spelled correctly and served by a configured provider.${context}`
18789
+ );
18790
+ }
18791
+ throw new Error(
18792
+ failures2.length ? `No model is available for a fast answer.${context}` : "No model is available for a fast answer \u2014 add a provider API key first."
18793
+ );
18201
18794
  }
18202
18795
  this.recordDecision(
18203
18796
  "model",
@@ -21788,10 +22381,10 @@ ${cur}`;
21788
22381
  }
21789
22382
 
21790
22383
  // src/retrieval/plan.ts
21791
- var CHARS_PER_TOKEN = 4;
22384
+ var CHARS_PER_TOKEN2 = 4;
21792
22385
  function cagCharBudget(contextWindowTokens, opts = {}) {
21793
22386
  const docFraction = opts.docFraction ?? 0.5;
21794
- const charsPerToken = opts.charsPerToken ?? CHARS_PER_TOKEN;
22387
+ const charsPerToken = opts.charsPerToken ?? CHARS_PER_TOKEN2;
21795
22388
  return Math.max(0, Math.floor(contextWindowTokens * docFraction * charsPerToken));
21796
22389
  }
21797
22390
  function planRetrieval(ctx) {
@@ -22370,7 +22963,7 @@ exports.CASCADE_IGNORE_FILE = CASCADE_IGNORE_FILE;
22370
22963
  exports.CASCADE_KEYSTORE_FILE = CASCADE_KEYSTORE_FILE;
22371
22964
  exports.CASCADE_MD_FILE = CASCADE_MD_FILE;
22372
22965
  exports.CASCADE_VERSION = CASCADE_VERSION;
22373
- exports.CHARS_PER_TOKEN = CHARS_PER_TOKEN;
22966
+ exports.CHARS_PER_TOKEN = CHARS_PER_TOKEN2;
22374
22967
  exports.COMPLEXITY_T2_COUNT = COMPLEXITY_T2_COUNT;
22375
22968
  exports.Cascade = Cascade;
22376
22969
  exports.CascadeCancelledError = CascadeCancelledError;