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/cli.cjs CHANGED
@@ -1667,6 +1667,15 @@ function findPricing(modelId, provider, opts = {}) {
1667
1667
  }
1668
1668
  return null;
1669
1669
  }
1670
+ function hasContextBands(model) {
1671
+ if (model.isLocal) return false;
1672
+ const fallbackProviders = model.provider === "azure" ? ["openai"] : model.provider === "gemini" ? ["vertex"] : [];
1673
+ const match = findPricing(model.id, model.provider, {
1674
+ baseModelId: model.baseModelId,
1675
+ fallbackProviders
1676
+ });
1677
+ return (match?.entry.tiers?.length ?? 0) > 1;
1678
+ }
1670
1679
  function pricingDatasetMeta() {
1671
1680
  const { schemaVersion, generatedAt, defaultCurrency } = DATASET;
1672
1681
  return { schemaVersion, generatedAt, defaultCurrency };
@@ -1810,7 +1819,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1810
1819
  var init_constants = __esm({
1811
1820
  "src/constants.ts"() {
1812
1821
  init_pricing();
1813
- CASCADE_VERSION = "0.72.0";
1822
+ CASCADE_VERSION = "0.74.0";
1814
1823
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1815
1824
  CASCADE_DB_FILE = ".cascade/memory.db";
1816
1825
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -2241,8 +2250,23 @@ var init_constants = __esm({
2241
2250
  });
2242
2251
 
2243
2252
  // src/utils/cost.ts
2244
- function resolveModelPricing(model) {
2253
+ function resolveModelPricing(model, opts = {}) {
2245
2254
  if (model.isLocal) return { input: 0, output: 0, unknown: false };
2255
+ if (opts.inputTokens != null && hasContextBands(model)) {
2256
+ const banded = resolvePricing(model, { inputTokens: opts.inputTokens });
2257
+ const base = resolvePricing(model, { inputTokens: 0 });
2258
+ if (!banded.unknown && !base.unknown) {
2259
+ const scale = (stamped, baseRate) => stamped > 0 && baseRate > 0 ? stamped / baseRate : 1;
2260
+ const inScale = scale(model.inputCostPer1kTokens, base.input);
2261
+ const outScale = scale(model.outputCostPer1kTokens, base.output);
2262
+ return {
2263
+ input: banded.input * inScale,
2264
+ output: banded.output * outScale,
2265
+ unknown: false,
2266
+ ...banded.estimatedFromProvider ? { estimatedFromProvider: banded.estimatedFromProvider } : {}
2267
+ };
2268
+ }
2269
+ }
2246
2270
  if (model.inputCostPer1kTokens > 0 || model.outputCostPer1kTokens > 0) {
2247
2271
  return {
2248
2272
  input: model.inputCostPer1kTokens,
@@ -2250,7 +2274,7 @@ function resolveModelPricing(model) {
2250
2274
  unknown: false
2251
2275
  };
2252
2276
  }
2253
- const fromDataset = resolvePricing(model);
2277
+ const fromDataset = resolvePricing(model, { inputTokens: opts.inputTokens });
2254
2278
  if (!fromDataset.unknown) {
2255
2279
  return {
2256
2280
  input: fromDataset.input,
@@ -2272,11 +2296,11 @@ function resolveModelPricing(model) {
2272
2296
  return { input: 0, output: 0, unknown: true };
2273
2297
  }
2274
2298
  function calculateCost(inputTokens, outputTokens, model) {
2275
- const { input, output } = resolveModelPricing(model);
2299
+ const { input, output } = resolveModelPricing(model, { inputTokens });
2276
2300
  return inputTokens / 1e3 * input + outputTokens / 1e3 * output;
2277
2301
  }
2278
2302
  function buildTokenUsage(inputTokens, outputTokens, model) {
2279
- const { input, output, unknown } = resolveModelPricing(model);
2303
+ const { input, output, unknown } = resolveModelPricing(model, { inputTokens });
2280
2304
  return {
2281
2305
  inputTokens,
2282
2306
  outputTokens,
@@ -2293,10 +2317,17 @@ var init_cost = __esm({
2293
2317
  });
2294
2318
 
2295
2319
  // src/providers/base.ts
2296
- var BaseProvider;
2320
+ var ProviderUnreachableError, BaseProvider;
2297
2321
  var init_base = __esm({
2298
2322
  "src/providers/base.ts"() {
2299
2323
  init_cost();
2324
+ ProviderUnreachableError = class extends Error {
2325
+ transient = true;
2326
+ constructor(message) {
2327
+ super(message);
2328
+ this.name = "ProviderUnreachableError";
2329
+ }
2330
+ };
2300
2331
  BaseProvider = class {
2301
2332
  config;
2302
2333
  model;
@@ -2368,7 +2399,9 @@ var init_model_filter = __esm({
2368
2399
  // src/providers/anthropic.ts
2369
2400
  var anthropic_exports = {};
2370
2401
  __export(anthropic_exports, {
2371
- AnthropicProvider: () => AnthropicProvider
2402
+ AnthropicProvider: () => AnthropicProvider,
2403
+ toAnthropicToolUse: () => toAnthropicToolUse,
2404
+ toAnthropicTools: () => toAnthropicTools
2372
2405
  });
2373
2406
  function anthropicThinkingParam(modelId, maxTokens) {
2374
2407
  if (!/claude-(opus|sonnet)-4/i.test(modelId)) return {};
@@ -2376,6 +2409,21 @@ function anthropicThinkingParam(modelId, maxTokens) {
2376
2409
  if (budget < 1024) return {};
2377
2410
  return { thinking: { type: "enabled", budget_tokens: budget } };
2378
2411
  }
2412
+ function toAnthropicTools(tools) {
2413
+ return tools.map((t) => ({
2414
+ name: t.name,
2415
+ description: t.description,
2416
+ input_schema: t.inputSchema
2417
+ }));
2418
+ }
2419
+ function toAnthropicToolUse(toolCalls) {
2420
+ return toolCalls.map((tc) => ({
2421
+ type: "tool_use",
2422
+ id: tc.id,
2423
+ name: tc.name,
2424
+ input: tc.input
2425
+ }));
2426
+ }
2379
2427
  var AnthropicProvider;
2380
2428
  var init_anthropic = __esm({
2381
2429
  "src/providers/anthropic.ts"() {
@@ -2404,11 +2452,7 @@ var init_anthropic = __esm({
2404
2452
  }
2405
2453
  async generateStream(options, onChunk) {
2406
2454
  const messages = this.convertMessages(options.messages);
2407
- const tools = options.tools?.map((t) => ({
2408
- name: t.name,
2409
- description: t.description,
2410
- input_schema: t.inputSchema
2411
- }));
2455
+ const tools = options.tools && toAnthropicTools(options.tools);
2412
2456
  let fullContent = "";
2413
2457
  let inputTokens = 0;
2414
2458
  let outputTokens = 0;
@@ -2536,14 +2580,7 @@ var init_anthropic = __esm({
2536
2580
  const content = [];
2537
2581
  const text = typeof m.content === "string" ? m.content : "";
2538
2582
  if (text) content.push({ type: "text", text });
2539
- for (const tc of m.toolCalls ?? []) {
2540
- content.push({
2541
- type: "tool_use",
2542
- id: tc.id,
2543
- name: tc.name,
2544
- input: tc.input
2545
- });
2546
- }
2583
+ content.push(...toAnthropicToolUse(m.toolCalls ?? []));
2547
2584
  if (content.length > 0) {
2548
2585
  result.push({ role: "assistant", content });
2549
2586
  }
@@ -2582,7 +2619,9 @@ var openai_exports = {};
2582
2619
  __export(openai_exports, {
2583
2620
  OpenAIProvider: () => OpenAIProvider,
2584
2621
  isParamShapeError: () => isParamShapeError,
2585
- isReasoningModel: () => isReasoningModel
2622
+ isReasoningModel: () => isReasoningModel,
2623
+ toOpenAIToolCalls: () => toOpenAIToolCalls,
2624
+ toOpenAITools: () => toOpenAITools
2586
2625
  });
2587
2626
  function isReasoningModel(id) {
2588
2627
  const m = (id || "").toLowerCase();
@@ -2593,6 +2632,26 @@ function isParamShapeError(err) {
2593
2632
  const msg = String(e?.message ?? e?.error?.message ?? "").toLowerCase();
2594
2633
  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"));
2595
2634
  }
2635
+ function toOpenAITools(tools) {
2636
+ return tools.map((t) => ({
2637
+ type: "function",
2638
+ function: {
2639
+ name: t.name,
2640
+ description: t.description,
2641
+ parameters: t.inputSchema
2642
+ }
2643
+ }));
2644
+ }
2645
+ function toOpenAIToolCalls(toolCalls) {
2646
+ return toolCalls.map((tc) => ({
2647
+ id: tc.id,
2648
+ type: "function",
2649
+ function: {
2650
+ name: tc.name,
2651
+ arguments: JSON.stringify(tc.input)
2652
+ }
2653
+ }));
2654
+ }
2596
2655
  var OpenAIProvider;
2597
2656
  var init_openai = __esm({
2598
2657
  "src/providers/openai.ts"() {
@@ -2619,14 +2678,7 @@ var init_openai = __esm({
2619
2678
  }
2620
2679
  async generateStream(options, onChunk) {
2621
2680
  const messages = this.convertMessages(options.messages, options.systemPrompt);
2622
- const tools = options.tools?.map((t) => ({
2623
- type: "function",
2624
- function: {
2625
- name: t.name,
2626
- description: t.description,
2627
- parameters: t.inputSchema
2628
- }
2629
- }));
2681
+ const tools = options.tools && toOpenAITools(options.tools);
2630
2682
  let fullContent = "";
2631
2683
  let inputTokens = 0;
2632
2684
  let outputTokens = 0;
@@ -2772,14 +2824,7 @@ var init_openai = __esm({
2772
2824
  result.push({
2773
2825
  role: "assistant",
2774
2826
  content: m.content || "",
2775
- tool_calls: m.toolCalls.map((toolCall) => ({
2776
- id: toolCall.id,
2777
- type: "function",
2778
- function: {
2779
- name: toolCall.name,
2780
- arguments: JSON.stringify(toolCall.input)
2781
- }
2782
- }))
2827
+ tool_calls: toOpenAIToolCalls(m.toolCalls)
2783
2828
  });
2784
2829
  } else {
2785
2830
  result.push({ role: m.role, content: m.content });
@@ -3046,8 +3091,42 @@ var init_gemini_schema = __esm({
3046
3091
  // src/providers/gemini.ts
3047
3092
  var gemini_exports = {};
3048
3093
  __export(gemini_exports, {
3049
- GeminiProvider: () => GeminiProvider
3094
+ GeminiProvider: () => GeminiProvider,
3095
+ toGeminiFunctionCalls: () => toGeminiFunctionCalls,
3096
+ toGeminiTools: () => toGeminiTools
3050
3097
  });
3098
+ function toGeminiTools(tools) {
3099
+ return tools.map((tool) => ({
3100
+ name: tool.name,
3101
+ description: tool.description,
3102
+ // Convert, never cast. Gemini's parameters field is an OpenAPI subset
3103
+ // that 400s on unknown keys, and MCP servers ship schemas carrying their
3104
+ // own extensions (GitHub's adds `x-mcp-header` to every header-bound
3105
+ // property). Casting sent those through verbatim and every request failed
3106
+ // before the model saw it. See providers/gemini-schema.ts.
3107
+ parameters: toGeminiParameters(tool.inputSchema)
3108
+ }));
3109
+ }
3110
+ function toGeminiFunctionCalls(toolCalls) {
3111
+ return toolCalls.map((tc) => ({
3112
+ functionCall: {
3113
+ name: tc.name,
3114
+ args: tc.input
3115
+ }
3116
+ }));
3117
+ }
3118
+ function isGeminiAuthStatus(status) {
3119
+ return status === 400 || status === 401 || status === 403;
3120
+ }
3121
+ async function describeGeminiError(resp) {
3122
+ try {
3123
+ const body = await resp.json();
3124
+ const detail = body?.error?.message ?? body?.error?.status;
3125
+ return detail ? ` \u2014 ${detail}` : "";
3126
+ } catch {
3127
+ return "";
3128
+ }
3129
+ }
3051
3130
  var GeminiProvider;
3052
3131
  var init_gemini = __esm({
3053
3132
  "src/providers/gemini.ts"() {
@@ -3077,7 +3156,7 @@ var init_gemini = __esm({
3077
3156
  { category: genai.HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: genai.HarmBlockThreshold.BLOCK_NONE },
3078
3157
  { category: genai.HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: genai.HarmBlockThreshold.BLOCK_NONE }
3079
3158
  ],
3080
- tools: options.tools?.length ? [{ functionDeclarations: options.tools.map(this.convertTool) }] : void 0,
3159
+ tools: options.tools?.length ? [{ functionDeclarations: toGeminiTools(options.tools) }] : void 0,
3081
3160
  abortSignal: options.signal
3082
3161
  }
3083
3162
  });
@@ -3139,9 +3218,7 @@ var init_gemini = __esm({
3139
3218
  }
3140
3219
  async listModels() {
3141
3220
  try {
3142
- const resp = await fetch(
3143
- `https://generativelanguage.googleapis.com/v1beta/models?key=${this.config.apiKey}`
3144
- );
3221
+ const resp = await this.fetchModelList();
3145
3222
  if (!resp.ok) {
3146
3223
  return Object.values(MODELS).filter((m) => m.provider === "gemini");
3147
3224
  }
@@ -3172,18 +3249,61 @@ var init_gemini = __esm({
3172
3249
  return Object.values(MODELS).filter((m) => m.provider === "gemini");
3173
3250
  }
3174
3251
  }
3252
+ /**
3253
+ * Is this KEY usable — not "does one particular model answer".
3254
+ *
3255
+ * This used to call countTokens() against `this.model.id`, which the router
3256
+ * fills from the first Gemini entry in the bundled catalogue. That made the
3257
+ * whole provider's fate depend on one hard-coded model id: a key that cannot
3258
+ * reach that one model — retired, not enabled for the key's project, served
3259
+ * on a different API version — failed the probe, and because every later step
3260
+ * is gated on the result (`validateCloudProviderModels` and
3261
+ * `discoverProviderModels` both return early for an unavailable provider) the
3262
+ * real model list was never fetched. Gemini vanished entirely, and the CLI
3263
+ * reported "add a provider API key first" about a key that was present and
3264
+ * working.
3265
+ *
3266
+ * Listing models asks the account-level question instead, the same one
3267
+ * OpenAI's probe asks, and it is the same request `listModels()` already
3268
+ * makes for discovery.
3269
+ *
3270
+ * Throws rather than returning false. The router logs the reason it catches,
3271
+ * and "bad key, wrong endpoint/deployment, or unreachable" — three guesses,
3272
+ * no answer — is what made this take a user report to find. A swallowed probe
3273
+ * error is the same defect that once hid a misconfigured Azure deployment.
3274
+ */
3175
3275
  async isAvailable() {
3276
+ if (!this.config.apiKey) throw new Error("no Gemini API key configured");
3277
+ let resp;
3176
3278
  try {
3177
- await this.client.models.countTokens({
3178
- model: this.model.id,
3179
- contents: [{ role: "user", parts: [{ text: "ping" }] }]
3180
- });
3181
- return true;
3182
- } catch {
3183
- return false;
3279
+ resp = await this.fetchModelList();
3280
+ } catch (err) {
3281
+ throw new ProviderUnreachableError(
3282
+ `could not reach the Gemini API: ${err instanceof Error ? err.message : String(err)}`
3283
+ );
3284
+ }
3285
+ if (resp.ok) return true;
3286
+ const status = `HTTP ${resp.status} ${resp.statusText}`.trim();
3287
+ const detail = await describeGeminiError(resp);
3288
+ if (isGeminiAuthStatus(resp.status)) {
3289
+ throw new Error(`Gemini rejected the API key: ${status}${detail}`);
3184
3290
  }
3291
+ throw new ProviderUnreachableError(`Gemini model list failed: ${status}${detail}`);
3185
3292
  }
3186
3293
  // ── Private ──────────────────────────────────
3294
+ /**
3295
+ * The account's model list. One place so the availability probe and
3296
+ * discovery cannot ask different questions of the same endpoint.
3297
+ *
3298
+ * The key goes in a header, not the query string: a URL carries into proxy
3299
+ * logs, error reports and shell history, and this one would carry the key
3300
+ * with it.
3301
+ */
3302
+ fetchModelList() {
3303
+ return fetch("https://generativelanguage.googleapis.com/v1beta/models", {
3304
+ headers: { "x-goog-api-key": this.config.apiKey ?? "" }
3305
+ });
3306
+ }
3187
3307
  buildContents(messages, extraImages) {
3188
3308
  const contents = [];
3189
3309
  for (const m of messages) {
@@ -3218,14 +3338,7 @@ var init_gemini = __esm({
3218
3338
  const parts = [];
3219
3339
  const textContent = typeof m.content === "string" ? m.content : "";
3220
3340
  if (textContent) parts.push({ text: textContent });
3221
- for (const tc of m.toolCalls ?? []) {
3222
- parts.push({
3223
- functionCall: {
3224
- name: tc.name,
3225
- args: tc.input
3226
- }
3227
- });
3228
- }
3341
+ parts.push(...toGeminiFunctionCalls(m.toolCalls ?? []));
3229
3342
  if (parts.length > 0) {
3230
3343
  contents.push({ role: "model", parts });
3231
3344
  }
@@ -3272,18 +3385,6 @@ var init_gemini = __esm({
3272
3385
  }
3273
3386
  return parts;
3274
3387
  }
3275
- convertTool(tool) {
3276
- return {
3277
- name: tool.name,
3278
- description: tool.description,
3279
- // Convert, never cast. Gemini's parameters field is an OpenAPI subset
3280
- // that 400s on unknown keys, and MCP servers ship schemas carrying their
3281
- // own extensions (GitHub's adds `x-mcp-header` to every header-bound
3282
- // property). Casting sent those through verbatim and every request failed
3283
- // before the model saw it. See providers/gemini-schema.ts.
3284
- parameters: toGeminiParameters(tool.inputSchema)
3285
- };
3286
- }
3287
3388
  };
3288
3389
  }
3289
3390
  });
@@ -3369,12 +3470,32 @@ var init_net = __esm({
3369
3470
  // src/providers/ollama.ts
3370
3471
  var ollama_exports = {};
3371
3472
  __export(ollama_exports, {
3372
- OllamaProvider: () => OllamaProvider
3473
+ OllamaProvider: () => OllamaProvider,
3474
+ toOllamaToolCalls: () => toOllamaToolCalls,
3475
+ toOllamaTools: () => toOllamaTools
3373
3476
  });
3374
3477
  function isToolCapable(modelName) {
3375
3478
  const name = modelName.toLowerCase();
3376
3479
  return TOOL_CAPABLE_FAMILIES.some((family) => name.includes(family));
3377
3480
  }
3481
+ function toOllamaTools(tools) {
3482
+ return tools.map((t) => ({
3483
+ type: "function",
3484
+ function: {
3485
+ name: t.name,
3486
+ description: t.description,
3487
+ parameters: t.inputSchema
3488
+ }
3489
+ }));
3490
+ }
3491
+ function toOllamaToolCalls(toolCalls) {
3492
+ return toolCalls.map((tc) => ({
3493
+ function: {
3494
+ name: tc.name,
3495
+ arguments: tc.input
3496
+ }
3497
+ }));
3498
+ }
3378
3499
  var TOOL_CAPABLE_FAMILIES, OllamaProvider;
3379
3500
  var init_ollama = __esm({
3380
3501
  "src/providers/ollama.ts"() {
@@ -3407,14 +3528,7 @@ var init_ollama = __esm({
3407
3528
  }
3408
3529
  async generateStream(options, onChunk) {
3409
3530
  const messages = this.convertMessages(options.messages, options.systemPrompt);
3410
- const ollamaTools = options.tools?.map((t) => ({
3411
- type: "function",
3412
- function: {
3413
- name: t.name,
3414
- description: t.description,
3415
- parameters: t.inputSchema
3416
- }
3417
- }));
3531
+ const ollamaTools = options.tools && toOllamaTools(options.tools);
3418
3532
  const response = await fetch(`${this.baseUrl}/api/chat`, {
3419
3533
  method: "POST",
3420
3534
  headers: { "Content-Type": "application/json" },
@@ -3594,12 +3708,7 @@ var init_ollama = __esm({
3594
3708
  result.push({
3595
3709
  role: "assistant",
3596
3710
  content: typeof m.content === "string" ? m.content : "",
3597
- tool_calls: m.toolCalls.map((tc) => ({
3598
- function: {
3599
- name: tc.name,
3600
- arguments: tc.input
3601
- }
3602
- }))
3711
+ tool_calls: toOllamaToolCalls(m.toolCalls)
3603
3712
  });
3604
3713
  continue;
3605
3714
  }
@@ -6274,6 +6383,7 @@ init_gemini();
6274
6383
  init_ollama();
6275
6384
  init_openai_compatible();
6276
6385
  init_openai();
6386
+ init_base();
6277
6387
 
6278
6388
  // src/core/router/selector.ts
6279
6389
  init_constants();
@@ -6593,6 +6703,21 @@ var DEFAULT_PROVIDER_TPM = {
6593
6703
  "openai-compatible": 3e4,
6594
6704
  ollama: Number.POSITIVE_INFINITY
6595
6705
  };
6706
+ function abortReason(signal) {
6707
+ return signal.reason instanceof Error ? signal.reason : new Error("Aborted while waiting for rate-limit capacity");
6708
+ }
6709
+ function sleepUnlessAborted(ms, signal) {
6710
+ if (!signal) return new Promise((resolve2) => setTimeout(resolve2, ms));
6711
+ return new Promise((resolve2) => {
6712
+ const done = () => {
6713
+ clearTimeout(timer);
6714
+ signal.removeEventListener("abort", done);
6715
+ resolve2();
6716
+ };
6717
+ const timer = setTimeout(done, ms);
6718
+ signal.addEventListener("abort", done, { once: true });
6719
+ });
6720
+ }
6596
6721
  var TpmLimiter = class {
6597
6722
  buckets = /* @__PURE__ */ new Map();
6598
6723
  constructor(overrides = {}) {
@@ -6611,9 +6736,10 @@ var TpmLimiter = class {
6611
6736
  * are reported back via `refund` when short, or simply settled at the next
6612
6737
  * refill.
6613
6738
  */
6614
- async acquire(provider, estimatedTokens) {
6739
+ async acquire(provider, estimatedTokens, signal) {
6615
6740
  const bucket = this.buckets.get(provider);
6616
6741
  if (!bucket || bucket.tokensPerMinute === Number.POSITIVE_INFINITY) return;
6742
+ if (signal?.aborted) throw abortReason(signal);
6617
6743
  const want = Math.min(estimatedTokens, bucket.tokensPerMinute);
6618
6744
  for (; ; ) {
6619
6745
  this.refill(bucket);
@@ -6623,7 +6749,8 @@ var TpmLimiter = class {
6623
6749
  }
6624
6750
  const deficit = want - bucket.available;
6625
6751
  const waitMs = Math.max(50, Math.ceil(deficit / bucket.tokensPerMinute * 6e4));
6626
- await new Promise((resolve2) => setTimeout(resolve2, waitMs));
6752
+ await sleepUnlessAborted(waitMs, signal);
6753
+ if (signal?.aborted) throw abortReason(signal);
6627
6754
  }
6628
6755
  }
6629
6756
  /**
@@ -6669,6 +6796,9 @@ var TpmLimiter = class {
6669
6796
  };
6670
6797
 
6671
6798
  // src/core/router/local-queue.ts
6799
+ function abortReason2(signal) {
6800
+ return signal.reason instanceof Error ? signal.reason : new Error("Aborted while waiting for a local inference slot");
6801
+ }
6672
6802
  var LocalRequestQueue = class {
6673
6803
  maxConcurrent;
6674
6804
  active = 0;
@@ -6679,9 +6809,16 @@ var LocalRequestQueue = class {
6679
6809
  /**
6680
6810
  * Acquire a queue slot. Returns a `release` function that MUST be called
6681
6811
  * when the inference call is done (even on error). Rejects if the slot
6682
- * cannot be acquired within `timeoutMs`.
6812
+ * cannot be acquired within `timeoutMs`, or as soon as `signal` fires.
6813
+ *
6814
+ * The signal matters as much as the timeout: with concurrency of 1 a caller
6815
+ * can sit here for half the inference timeout — 150 seconds by default —
6816
+ * and until it was wired up, neither cancelling the run nor spending its
6817
+ * budget got it out. It waited out the window for a slot it would drop the
6818
+ * moment it received one.
6683
6819
  */
6684
- async acquire(timeoutMs) {
6820
+ async acquire(timeoutMs, signal) {
6821
+ if (signal?.aborted) throw abortReason2(signal);
6685
6822
  if (this.active < this.maxConcurrent) {
6686
6823
  this.active++;
6687
6824
  return this.makeRelease();
@@ -6689,23 +6826,36 @@ var LocalRequestQueue = class {
6689
6826
  return new Promise((resolve2, reject) => {
6690
6827
  let settled = false;
6691
6828
  let timer;
6829
+ const cleanup = () => {
6830
+ if (timer !== void 0) clearTimeout(timer);
6831
+ signal?.removeEventListener("abort", onAbort);
6832
+ };
6692
6833
  const resolver = (release) => {
6693
6834
  if (settled) return;
6694
6835
  settled = true;
6695
- if (timer !== void 0) clearTimeout(timer);
6836
+ cleanup();
6696
6837
  resolve2(release);
6697
6838
  };
6839
+ const abandon = () => {
6840
+ if (settled) return false;
6841
+ settled = true;
6842
+ cleanup();
6843
+ const idx = this.queue.indexOf(resolver);
6844
+ if (idx !== -1) this.queue.splice(idx, 1);
6845
+ return true;
6846
+ };
6847
+ function onAbort() {
6848
+ if (abandon()) reject(abortReason2(signal));
6849
+ }
6698
6850
  if (timeoutMs !== void 0 && timeoutMs > 0) {
6699
6851
  timer = setTimeout(() => {
6700
- if (settled) return;
6701
- settled = true;
6702
- const idx = this.queue.indexOf(resolver);
6703
- if (idx !== -1) this.queue.splice(idx, 1);
6852
+ if (!abandon()) return;
6704
6853
  reject(new Error(
6705
6854
  `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.`
6706
6855
  ));
6707
6856
  }, timeoutMs);
6708
6857
  }
6858
+ signal?.addEventListener("abort", onAbort, { once: true });
6709
6859
  this.queue.push(resolver);
6710
6860
  });
6711
6861
  }
@@ -6846,6 +6996,116 @@ function fileDeadModelPersistence(filePath) {
6846
6996
  init_constants();
6847
6997
  init_cost();
6848
6998
 
6999
+ // src/core/context/compaction.ts
7000
+ var CHARS_PER_TOKEN = 4;
7001
+ function estimateTokens(text) {
7002
+ if (!text) return 0;
7003
+ return Math.max(1, Math.ceil(text.length / CHARS_PER_TOKEN));
7004
+ }
7005
+ function contentToText(content) {
7006
+ if (typeof content === "string") return content;
7007
+ return content.map((c) => c.type === "text" ? c.text : c.type === "tool_result" ? c.content : `[${c.type}]`).join(" ");
7008
+ }
7009
+ function messagesTokens(messages) {
7010
+ return messages.reduce((n, m) => n + estimateTokens(contentToText(m.content)), 0);
7011
+ }
7012
+ function atomize(text, maxChars) {
7013
+ const seps = ["\n\n", "\n", ". ", " "];
7014
+ let units = [text];
7015
+ for (const sep of seps) {
7016
+ if (units.every((u) => u.length <= maxChars)) break;
7017
+ const next = [];
7018
+ for (const u of units) {
7019
+ if (u.length <= maxChars) {
7020
+ next.push(u);
7021
+ continue;
7022
+ }
7023
+ const parts = u.split(sep);
7024
+ parts.forEach((p, i) => next.push(i < parts.length - 1 ? p + sep : p));
7025
+ }
7026
+ units = next;
7027
+ }
7028
+ const out = [];
7029
+ for (const u of units) {
7030
+ if (u.length <= maxChars) out.push(u);
7031
+ else for (let i = 0; i < u.length; i += maxChars) out.push(u.slice(i, i + maxChars));
7032
+ }
7033
+ return out;
7034
+ }
7035
+ function chunkText(text, opts) {
7036
+ const maxChars = Math.max(1, Math.floor(opts.maxTokens * 4));
7037
+ const overlap = Math.min(maxChars - 1, Math.max(0, Math.floor(maxChars * (opts.overlapRatio ?? 0.1))));
7038
+ const trimmed = text.trim();
7039
+ if (!trimmed) return [];
7040
+ if (trimmed.length <= maxChars) return [trimmed];
7041
+ const units = atomize(trimmed, maxChars);
7042
+ const chunks = [];
7043
+ let cur = "";
7044
+ for (const u of units) {
7045
+ if (cur && cur.length + u.length > maxChars) {
7046
+ chunks.push(cur.trim());
7047
+ cur = overlap > 0 ? cur.slice(-overlap) : "";
7048
+ }
7049
+ cur += u;
7050
+ }
7051
+ if (cur.trim()) chunks.push(cur.trim());
7052
+ return chunks;
7053
+ }
7054
+ function needsCompaction(totalTokens, contextWindow, reserve = 0.2) {
7055
+ if (!contextWindow || contextWindow <= 0) return false;
7056
+ return totalTokens > contextWindow * (1 - reserve);
7057
+ }
7058
+ async function mapReduceCompact(text, opts) {
7059
+ const capChars = opts.capTokens * 4;
7060
+ let working = text;
7061
+ let truncated = false;
7062
+ if (working.length > capChars) {
7063
+ working = working.slice(0, capChars);
7064
+ truncated = true;
7065
+ }
7066
+ const instruction = opts.instruction ?? "Summarize this content, preserving key facts, names, numbers, and decisions.";
7067
+ let chunks = chunkText(working, { maxTokens: opts.chunkTokens });
7068
+ if (chunks.length <= 1) return { text: working, chunks: chunks.length, calls: 0, truncated };
7069
+ let calls = 0;
7070
+ let parts = await Promise.all(
7071
+ chunks.map((c, i) => {
7072
+ calls++;
7073
+ return opts.summarize(c, `${instruction} (part ${i + 1} of ${chunks.length})`);
7074
+ })
7075
+ );
7076
+ let combined = parts.join("\n\n");
7077
+ const maxPasses = opts.maxReducePasses ?? 3;
7078
+ for (let pass = 0; pass < maxPasses && estimateTokens(combined) > opts.targetTokens; pass++) {
7079
+ const groups = chunkText(combined, { maxTokens: opts.chunkTokens });
7080
+ if (groups.length <= 1) break;
7081
+ parts = await Promise.all(
7082
+ groups.map((g) => {
7083
+ calls++;
7084
+ return opts.summarize(g, "Merge these partial summaries into one cohesive summary; keep all distinct facts.");
7085
+ })
7086
+ );
7087
+ combined = parts.join("\n\n");
7088
+ }
7089
+ const note = truncated ? "\n\n[Note: input exceeded the extended-context cap and was truncated.]" : "";
7090
+ return { text: combined + note, chunks: chunks.length, calls, truncated };
7091
+ }
7092
+ async function rollingSummary(messages, opts) {
7093
+ if (messages.length <= opts.keepRecent) return messages;
7094
+ const older = messages.slice(0, messages.length - opts.keepRecent);
7095
+ const recent = messages.slice(messages.length - opts.keepRecent);
7096
+ const transcript = older.map((m) => `${m.role}: ${contentToText(m.content)}`).join("\n");
7097
+ const summary = await opts.summarize(
7098
+ transcript,
7099
+ "Summarize this earlier conversation. Preserve facts, decisions, names, numbers, and any open threads or todos."
7100
+ );
7101
+ const summaryMessage = {
7102
+ role: "system",
7103
+ content: `Summary of earlier conversation (folded to fit the context window):
7104
+ ${summary}`
7105
+ };
7106
+ return [summaryMessage, ...recent];
7107
+ }
7108
+
6849
7109
  // src/utils/retry.ts
6850
7110
  var CascadeCancelledError = class extends Error {
6851
7111
  constructor(reason) {
@@ -6867,6 +7127,162 @@ async function withTimeout(promise, timeoutMs, errorMessage = "Operation timed o
6867
7127
  if (timer !== void 0) clearTimeout(timer);
6868
7128
  }
6869
7129
  }
7130
+ async function withTimeoutAbort(run, timeoutMs, errorMessage = "Operation timed out", outer) {
7131
+ const outers = (Array.isArray(outer) ? outer : [outer]).filter((sig) => sig !== void 0);
7132
+ const controller = new AbortController();
7133
+ let timer;
7134
+ let rejectRace;
7135
+ const settle = (reason) => {
7136
+ rejectRace?.(reason);
7137
+ controller.abort(reason);
7138
+ };
7139
+ const abortOuter = () => {
7140
+ const fired = outers.find((sig) => sig.aborted);
7141
+ settle(fired?.reason instanceof Error ? fired.reason : new CascadeCancelledError("Run cancelled"));
7142
+ };
7143
+ const failPromise = new Promise((_, reject) => {
7144
+ rejectRace = reject;
7145
+ timer = setTimeout(() => settle(new Error(errorMessage)), timeoutMs);
7146
+ });
7147
+ try {
7148
+ if (outers.some((sig) => sig.aborted)) {
7149
+ abortOuter();
7150
+ return await failPromise;
7151
+ }
7152
+ for (const sig of outers) sig.addEventListener("abort", abortOuter, { once: true });
7153
+ return await Promise.race([run(controller.signal), failPromise]);
7154
+ } finally {
7155
+ if (timer !== void 0) clearTimeout(timer);
7156
+ for (const sig of outers) sig.removeEventListener("abort", abortOuter);
7157
+ }
7158
+ }
7159
+ function anySignal(sources) {
7160
+ const live = sources.filter((s) => s !== void 0);
7161
+ if (live.length === 0) return { release: () => {
7162
+ } };
7163
+ if (live.length === 1) return { signal: live[0], release: () => {
7164
+ } };
7165
+ const controller = new AbortController();
7166
+ function detach() {
7167
+ for (const s of live) s.removeEventListener("abort", onAbort);
7168
+ }
7169
+ function onAbort() {
7170
+ const fired = live.find((s) => s.aborted);
7171
+ controller.abort(fired?.reason instanceof Error ? fired.reason : new CascadeCancelledError("Run cancelled"));
7172
+ detach();
7173
+ }
7174
+ if (live.some((s) => s.aborted)) {
7175
+ onAbort();
7176
+ return { signal: controller.signal, release: detach };
7177
+ }
7178
+ for (const s of live) s.addEventListener("abort", onAbort, { once: true });
7179
+ return { signal: controller.signal, release: detach };
7180
+ }
7181
+
7182
+ // src/core/router/wire-profile.ts
7183
+ init_anthropic();
7184
+ init_gemini();
7185
+ init_ollama();
7186
+ init_openai();
7187
+ var textAndImageOnly = (block) => block.type === "text" || block.type === "image";
7188
+ var ANTHROPIC = {
7189
+ // convertMessages: `if (m.role === 'system') continue` — history system
7190
+ // messages are skipped outright; only options.systemPrompt goes as system
7191
+ // input. Compaction emits exactly such a message, holding a summary of the
7192
+ // whole conversation, so charging it refused runs over input never sent.
7193
+ dropsMessage: (m) => m.role === "system",
7194
+ blockHandling: (m) => {
7195
+ if (m.role === "tool") return "stringified";
7196
+ if (m.role === "assistant") return "dropped";
7197
+ return "blocks";
7198
+ },
7199
+ sendsToolCalls: (m) => m.role === "assistant",
7200
+ sendsBlock: textAndImageOnly,
7201
+ sendsToolCallId: true,
7202
+ sizeToolCalls: toAnthropicToolUse,
7203
+ sendsUrlImages: true,
7204
+ readsTopLevelImages: false,
7205
+ sizeTools: toAnthropicTools
7206
+ };
7207
+ var GEMINI = {
7208
+ // buildContents folds a system message into the next user turn, but only
7209
+ // when it has text: array content collapses to '' and the message is
7210
+ // skipped, as is a whitespace-only string.
7211
+ dropsMessage: (m) => m.role === "system" && !(typeof m.content === "string" ? m.content : "").trim(),
7212
+ blockHandling: (m) => {
7213
+ if (m.role === "tool") return "stringified";
7214
+ if (m.role === "assistant" || m.role === "system") return "dropped";
7215
+ return "blocks";
7216
+ },
7217
+ sendsToolCalls: (m) => m.role === "assistant",
7218
+ sendsBlock: textAndImageOnly,
7219
+ sendsToolCallId: true,
7220
+ sizeToolCalls: toGeminiFunctionCalls,
7221
+ sendsUrlImages: false,
7222
+ readsTopLevelImages: true,
7223
+ sizeTools: toGeminiTools
7224
+ };
7225
+ var OPENAI = {
7226
+ dropsMessage: () => false,
7227
+ blockHandling: (m) => {
7228
+ if (m.role === "system" || m.role === "tool") return "dropped";
7229
+ return "blocks";
7230
+ },
7231
+ // The tool_calls branch is inside `if (typeof m.content === 'string')`; an
7232
+ // assistant turn with array content falls through to the parts branch and
7233
+ // its tool calls are never attached.
7234
+ sendsToolCalls: (m) => m.role === "assistant" && typeof m.content === "string",
7235
+ sendsBlock: textAndImageOnly,
7236
+ sendsToolCallId: true,
7237
+ sizeToolCalls: toOpenAIToolCalls,
7238
+ sendsUrlImages: true,
7239
+ readsTopLevelImages: false,
7240
+ sizeTools: toOpenAITools
7241
+ };
7242
+ var OLLAMA = {
7243
+ dropsMessage: () => false,
7244
+ blockHandling: (m) => {
7245
+ if (m.role === "system") return "dropped";
7246
+ if (m.role === "tool") return "stringified";
7247
+ if (m.role === "assistant" && m.toolCalls?.length) return "dropped";
7248
+ return "blocks";
7249
+ },
7250
+ sendsToolCalls: (m) => m.role === "assistant",
7251
+ // The image split maps `b.image.data` with no check on `type`, so a URL is
7252
+ // submitted too (as a URL string in the `images` array).
7253
+ sendsBlock: textAndImageOnly,
7254
+ sendsToolCallId: false,
7255
+ sizeToolCalls: toOllamaToolCalls,
7256
+ sendsUrlImages: true,
7257
+ readsTopLevelImages: false,
7258
+ sizeTools: toOllamaTools
7259
+ };
7260
+ var PROFILES = {
7261
+ anthropic: ANTHROPIC,
7262
+ gemini: GEMINI,
7263
+ openai: OPENAI,
7264
+ // AzureOpenAIProvider and OpenAICompatibleProvider both extend OpenAIProvider
7265
+ // and neither overrides convertMessages.
7266
+ azure: OPENAI,
7267
+ "openai-compatible": OPENAI,
7268
+ ollama: OLLAMA
7269
+ };
7270
+ function wireProfile(provider) {
7271
+ return PROFILES[provider] ?? OPENAI;
7272
+ }
7273
+ function geminiImageCopies(messages) {
7274
+ for (const m of messages) {
7275
+ if (m.role === "user") {
7276
+ const laterUserTurn = messages.slice(messages.indexOf(m) + 1).some((x) => x.role === "user");
7277
+ return laterUserTurn ? 2 : 1;
7278
+ }
7279
+ const text = typeof m.content === "string" ? m.content : "";
7280
+ if (m.role === "system" && text.trim()) return 1;
7281
+ if (m.role === "tool") return 1;
7282
+ if (m.role === "assistant" && (text || m.toolCalls?.length)) return 1;
7283
+ }
7284
+ return 1;
7285
+ }
6870
7286
 
6871
7287
  // src/core/router/model-profiler.ts
6872
7288
  var SKIP_PATTERN = /embed|dall-e|whisper|tts|vision|instruct-vision|rerank/i;
@@ -7376,6 +7792,36 @@ function azureModelForTier(tier, ranked) {
7376
7792
  if (tier === "T3") return ranked[ranked.length - 1];
7377
7793
  return ranked[Math.min(1, ranked.length - 1)];
7378
7794
  }
7795
+ var IMAGE_TOKENS_MAX = 2e3;
7796
+ var IMAGE_TOKENS_EACH = IMAGE_TOKENS_MAX;
7797
+ function safeJson(value) {
7798
+ try {
7799
+ return JSON.stringify(value) ?? "";
7800
+ } catch {
7801
+ return "";
7802
+ }
7803
+ }
7804
+ function newRunAbort() {
7805
+ const controller = new AbortController();
7806
+ EventEmitter.setMaxListeners(64, controller.signal);
7807
+ return controller;
7808
+ }
7809
+ var TOKENS_PER_MESSAGE_FRAMING = 4;
7810
+ function guardTokens(text) {
7811
+ if (!text) return 0;
7812
+ let asciiChars = 0;
7813
+ let denseBytes = 0;
7814
+ for (const ch of text) {
7815
+ const cp = ch.codePointAt(0);
7816
+ if (cp <= 127) {
7817
+ asciiChars++;
7818
+ continue;
7819
+ }
7820
+ denseBytes += cp <= 2047 ? 2 : cp <= 65535 ? 3 : 4;
7821
+ }
7822
+ if (denseBytes === 0) return estimateTokens(text);
7823
+ return Math.ceil(asciiChars / CHARS_PER_TOKEN) + denseBytes;
7824
+ }
7379
7825
  var DISCOVERY_TTL_MS = 15 * 60 * 1e3;
7380
7826
  var DISCOVERY_TIMEOUT_MS = 4e3;
7381
7827
  var cloudDiscoveryCache = /* @__PURE__ */ new Map();
@@ -7428,6 +7874,33 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7428
7874
  // start of every `cascade run`, independent of the session-wide budget.
7429
7875
  runTokens = 0;
7430
7876
  runCostUsd = 0;
7877
+ /**
7878
+ * Budget held for calls that are IN FLIGHT — admitted by the preflight check
7879
+ * but not yet counted by recordStats. Without these, concurrent calls all
7880
+ * measure themselves against the same unspent allowance and are all admitted.
7881
+ * Released when each call settles; reset by beginRun() alongside the totals.
7882
+ */
7883
+ reservedTokens = 0;
7884
+ reservedCostUsd = 0;
7885
+ /**
7886
+ * Fires when this run's budget is spent, cancelling work already in flight.
7887
+ *
7888
+ * The kill switch was previously only a flag, which stops calls that have
7889
+ * not yet submitted. A parallel wave's earlier members are already at the
7890
+ * provider by then, and they carried on generating for a run whose output
7891
+ * would be discarded. Every provider call chains to this, so tripping the
7892
+ * ceiling reaches the requests that are running, not just the ones queued.
7893
+ */
7894
+ runAbort = newRunAbort();
7895
+ /**
7896
+ * Bumped by every beginRun(). A release handle remembers the generation it
7897
+ * was issued in and does nothing if that has moved on: beginRun() zeroes the
7898
+ * counters, so a call still in flight from the previous run would otherwise
7899
+ * subtract its estimate from the NEW run's totals and drive them negative —
7900
+ * leaving later preflights believing there is more allowance than the cap
7901
+ * actually holds, which is worse than no gate at all.
7902
+ */
7903
+ runGeneration = 0;
7431
7904
  runBudgetExceeded = false;
7432
7905
  runBudgetExceededReason;
7433
7906
  /**
@@ -7597,7 +8070,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7597
8070
  try {
7598
8071
  const cfg = this.config.providers.find((p) => p.type === model.provider) ?? { type: model.provider };
7599
8072
  const provider = this.createProvider(cfg, model);
7600
- const result = await withTimeout(provider.generate({
8073
+ const result = await withTimeoutAbort((signal) => provider.generate({
7601
8074
  messages: [{ role: "user", content: "Call the echo tool with text set to 'ping'. Use the tool; do not answer in prose." }],
7602
8075
  tools: [{
7603
8076
  name: "echo",
@@ -7605,7 +8078,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7605
8078
  inputSchema: { type: "object", properties: { text: { type: "string", description: "Text to echo" } }, required: ["text"] }
7606
8079
  }],
7607
8080
  maxTokens: 80,
7608
- temperature: 0
8081
+ temperature: 0,
8082
+ signal
7609
8083
  }), 3e4, "tool-support probe timed out");
7610
8084
  return (result.toolCalls?.length ?? 0) > 0;
7611
8085
  } catch {
@@ -7726,9 +8200,12 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7726
8200
  this.runBudgetExceededReason ?? "Per-task budget exceeded."
7727
8201
  );
7728
8202
  }
8203
+ const runGeneration = this.runGeneration;
8204
+ const runAbort = this.runAbort;
8205
+ const runSignal = this.runSignal;
7729
8206
  options = applyTierLimits(options, tier, this.config?.tierLimits);
7730
- if (this.runSignal && !options.signal) {
7731
- options = { ...options, signal: this.runSignal };
8207
+ if (runSignal && !options.signal) {
8208
+ options = { ...options, signal: runSignal };
7732
8209
  }
7733
8210
  if (options.model && !requireVision) {
7734
8211
  this.ensureProvider(options.model, this.config.providers);
@@ -7750,58 +8227,84 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7750
8227
  if (!model) throw new Error(`No model available for tier ${tier}`);
7751
8228
  const provider = this.getProvider(model);
7752
8229
  if (!provider) throw new Error(`No provider for model ${model.id}`);
8230
+ let releaseReservation = this.enforcePreflightBudget(model, options);
7753
8231
  const requestedTokens = options.maxTokens ?? model.maxOutputTokens ?? 1024;
7754
8232
  const estimatedTokens = Math.min(requestedTokens, model.maxOutputTokens ?? requestedTokens) + 512;
7755
8233
  if (this.tpmLimiter) {
7756
- await this.tpmLimiter.acquire(model.provider, estimatedTokens);
8234
+ const wait = anySignal([options.signal, runAbort.signal]);
8235
+ try {
8236
+ await this.tpmLimiter.acquire(model.provider, estimatedTokens, wait.signal);
8237
+ } catch (err) {
8238
+ releaseReservation?.();
8239
+ throw this.asAbortFailure(err, options.signal, runSignal);
8240
+ } finally {
8241
+ wait.release();
8242
+ }
7757
8243
  }
7758
8244
  const useStream = Boolean(onChunk) && model.supportsStreaming && typeof provider.generateStream === "function";
7759
8245
  let releaseLocalSlot;
7760
8246
  if (model.isLocal) {
7761
8247
  const inferenceTimeoutMs = this.config.localInferenceTimeoutMs ?? 3e5;
7762
8248
  const queueWaitMs = Math.round(inferenceTimeoutMs / 2);
7763
- releaseLocalSlot = await this.localQueue.acquire(queueWaitMs);
8249
+ const wait = anySignal([options.signal, runAbort.signal]);
8250
+ try {
8251
+ releaseLocalSlot = await this.localQueue.acquire(queueWaitMs, wait.signal);
8252
+ } catch (err) {
8253
+ releaseReservation?.();
8254
+ throw this.asAbortFailure(err, options.signal, runSignal);
8255
+ } finally {
8256
+ wait.release();
8257
+ }
7764
8258
  }
7765
8259
  try {
8260
+ if (runGeneration === this.runGeneration && this.runBudgetExceeded) {
8261
+ this.tpmLimiter?.refund(model.provider, estimatedTokens);
8262
+ throw new _CascadeRouter.BudgetExceededError(
8263
+ this.runBudgetExceededReason ?? "Per-task budget exceeded."
8264
+ );
8265
+ }
7766
8266
  let result;
7767
8267
  if (model.isLocal) {
7768
8268
  const inferenceTimeoutMs = this.config.localInferenceTimeoutMs ?? 3e5;
7769
- const inferencePromise = useStream && onChunk ? provider.generateStream(options, (chunk) => {
7770
- const text = typeof chunk?.text === "string" ? chunk.text : "";
7771
- if (text) onChunk({ ...chunk, text });
7772
- }) : provider.generate(options);
7773
- result = await withTimeout(
7774
- inferencePromise,
8269
+ result = await withTimeoutAbort(
8270
+ (signal) => useStream && onChunk ? provider.generateStream({ ...options, signal }, (chunk) => {
8271
+ const text = typeof chunk?.text === "string" ? chunk.text : "";
8272
+ if (text) onChunk({ ...chunk, text });
8273
+ }) : provider.generate({ ...options, signal }),
7775
8274
  inferenceTimeoutMs,
7776
- `Local model ${model.id} inference timed out after ${inferenceTimeoutMs}ms`
8275
+ `Local model ${model.id} inference timed out after ${inferenceTimeoutMs}ms`,
8276
+ [options.signal, runAbort.signal]
7777
8277
  );
7778
8278
  } else if (useStream && onChunk) {
7779
8279
  const cloudTimeoutMs = this.config.cloudInferenceTimeoutMs ?? 12e4;
7780
8280
  try {
7781
- result = await withTimeout(
7782
- provider.generateStream(options, (chunk) => {
8281
+ result = await withTimeoutAbort(
8282
+ (signal) => provider.generateStream({ ...options, signal }, (chunk) => {
7783
8283
  const text = typeof chunk?.text === "string" ? chunk.text : "";
7784
8284
  if (text) onChunk({ ...chunk, text });
7785
8285
  }),
7786
8286
  cloudTimeoutMs,
7787
- `Model ${model.id} stream timed out after ${cloudTimeoutMs}ms`
8287
+ `Model ${model.id} stream timed out after ${cloudTimeoutMs}ms`,
8288
+ [options.signal, runAbort.signal]
7788
8289
  );
7789
8290
  } catch (streamErr) {
7790
- if (streamErr instanceof Error && streamErr.name === "AbortError" || this.runSignal?.aborted || options.signal?.aborted) {
8291
+ if (streamErr instanceof Error && streamErr.name === "AbortError" || runSignal?.aborted || options.signal?.aborted) {
7791
8292
  throw streamErr;
7792
8293
  }
7793
- result = await withTimeout(
7794
- provider.generate(options),
8294
+ result = await withTimeoutAbort(
8295
+ (signal) => provider.generate({ ...options, signal }),
7795
8296
  cloudTimeoutMs,
7796
- `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`
8297
+ `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`,
8298
+ [options.signal, runAbort.signal]
7797
8299
  );
7798
8300
  }
7799
8301
  } else {
7800
8302
  const cloudTimeoutMs = this.config.cloudInferenceTimeoutMs ?? 12e4;
7801
- result = await withTimeout(
7802
- provider.generate(options),
8303
+ result = await withTimeoutAbort(
8304
+ (signal) => provider.generate({ ...options, signal }),
7803
8305
  cloudTimeoutMs,
7804
- `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`
8306
+ `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`,
8307
+ [options.signal, runAbort.signal]
7805
8308
  );
7806
8309
  }
7807
8310
  const corrected = buildTokenUsage(
@@ -7820,11 +8323,12 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7820
8323
  if (!result || typeof result.content !== "string" || !result.usage) {
7821
8324
  throw new Error(`Provider ${model.provider}:${model.id} returned an invalid generation result.`);
7822
8325
  }
7823
- this.recordStats(tier, model, result.usage, options.featureTag);
8326
+ this.recordStats(tier, model, result.usage, options.featureTag, runGeneration);
7824
8327
  this.failover.recordSuccess(model.provider);
7825
8328
  return result;
7826
8329
  } catch (err) {
7827
- if (err instanceof Error && err.name === "AbortError" || this.runSignal?.aborted || options.signal?.aborted) {
8330
+ if (err instanceof _CascadeRouter.BudgetExceededError) throw err;
8331
+ if (err instanceof Error && err.name === "AbortError" || runSignal?.aborted || options.signal?.aborted) {
7828
8332
  throw new CascadeCancelledError("Run cancelled");
7829
8333
  }
7830
8334
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -7842,6 +8346,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7842
8346
  });
7843
8347
  releaseLocalSlot?.();
7844
8348
  releaseLocalSlot = void 0;
8349
+ releaseReservation?.();
8350
+ releaseReservation = void 0;
7845
8351
  const retryOpts = options.model && options.model.id === model.id ? { ...options, model: void 0 } : options;
7846
8352
  return this.generate(tier, retryOpts, onChunk, requireVision);
7847
8353
  }
@@ -7863,6 +8369,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7863
8369
  });
7864
8370
  releaseLocalSlot?.();
7865
8371
  releaseLocalSlot = void 0;
8372
+ releaseReservation?.();
8373
+ releaseReservation = void 0;
7866
8374
  const base = options.model && options.model.id === model.id ? { ...options, model: void 0 } : options;
7867
8375
  const retryOpts = { ...base, _notFoundDepth: depth };
7868
8376
  return this.generate(tier, retryOpts, onChunk, requireVision);
@@ -7871,6 +8379,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7871
8379
  throw err;
7872
8380
  } finally {
7873
8381
  releaseLocalSlot?.();
8382
+ releaseReservation?.();
8383
+ releaseReservation = void 0;
7874
8384
  }
7875
8385
  }
7876
8386
  getModelForTier(tier) {
@@ -8133,12 +8643,28 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
8133
8643
  return this.selector?.getAllAvailableModels() ?? [];
8134
8644
  }
8135
8645
  // ── Private ──────────────────────────────────
8646
+ /**
8647
+ * Why each configured provider failed its availability probe, kept so the
8648
+ * surfaces can say something better than "add an API key" to a user who
8649
+ * already has one. Cleared and refilled by each detectAvailableProviders().
8650
+ */
8651
+ probeFailures = /* @__PURE__ */ new Map();
8652
+ /**
8653
+ * What stopped the configured providers from being usable, if anything.
8654
+ * Empty when every configured provider passed — or when none was configured,
8655
+ * which is a different problem with a different answer.
8656
+ */
8657
+ providerProbeFailures() {
8658
+ return [...this.probeFailures].map(([provider, reason]) => ({ provider, reason }));
8659
+ }
8136
8660
  /** Logs why a configured provider failed its availability probe. */
8137
8661
  emitProbeFailure(type, reason) {
8662
+ this.probeFailures.set(type, reason);
8138
8663
  console.warn(`[router] provider "${type}" is not available: ${reason}`);
8139
8664
  }
8140
8665
  async detectAvailableProviders(configs) {
8141
8666
  const available = /* @__PURE__ */ new Set();
8667
+ this.probeFailures.clear();
8142
8668
  const checks = configs.map(async (cfg) => {
8143
8669
  try {
8144
8670
  const testModel = this.getAnyModelForProvider(cfg.type);
@@ -8148,7 +8674,13 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
8148
8674
  if (ok) available.add(cfg.type);
8149
8675
  else this.emitProbeFailure(cfg.type, "availability check returned false (bad key, wrong endpoint/deployment, or unreachable)");
8150
8676
  } catch (err) {
8151
- this.emitProbeFailure(cfg.type, err instanceof Error ? err.message : String(err));
8677
+ const reason = err instanceof Error ? err.message : String(err);
8678
+ if (err instanceof ProviderUnreachableError) {
8679
+ available.add(cfg.type);
8680
+ console.warn(`[router] provider "${cfg.type}" probe did not complete: ${reason} \u2014 continuing with it enabled`);
8681
+ return;
8682
+ }
8683
+ this.emitProbeFailure(cfg.type, reason);
8152
8684
  }
8153
8685
  });
8154
8686
  await Promise.allSettled(checks);
@@ -8241,7 +8773,14 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
8241
8773
  }
8242
8774
  return void 0;
8243
8775
  }
8244
- recordStats(tier, model, usage, featureTag) {
8776
+ /**
8777
+ * `runGeneration` is the generation the call was ADMITTED in — see
8778
+ * `generate()`. Session-wide accounting always applies (the spend is real
8779
+ * whenever it lands), but the per-RUN ceiling is only meaningful for the run
8780
+ * the call belongs to, so a straggler from a finished run is left out of it.
8781
+ * Omitted by the direct callers in tests, which have no run to straddle.
8782
+ */
8783
+ recordStats(tier, model, usage, featureTag, runGeneration) {
8245
8784
  this.stats.totalTokens += usage.totalTokens;
8246
8785
  this.stats.totalCostUsd += usage.estimatedCostUsd;
8247
8786
  this.sessionCostUsd += usage.estimatedCostUsd;
@@ -8266,10 +8805,13 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
8266
8805
  });
8267
8806
  }
8268
8807
  }
8269
- this.runTokens += usage.totalTokens;
8270
- this.runCostUsd += usage.estimatedCostUsd;
8808
+ const currentRun = runGeneration === void 0 || runGeneration === this.runGeneration;
8809
+ if (currentRun) {
8810
+ this.runTokens += usage.totalTokens;
8811
+ this.runCostUsd += usage.estimatedCostUsd;
8812
+ }
8271
8813
  this.updateBudgetState();
8272
- this.enforceRunBudget();
8814
+ if (currentRun) this.enforceRunBudget();
8273
8815
  }
8274
8816
  /**
8275
8817
  * Resets per-run accounting at the start of each `cascade run`. Session
@@ -8279,9 +8821,158 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
8279
8821
  beginRun() {
8280
8822
  this.runTokens = 0;
8281
8823
  this.runCostUsd = 0;
8824
+ this.reservedTokens = 0;
8825
+ this.reservedCostUsd = 0;
8826
+ this.runAbort = newRunAbort();
8827
+ this.runGeneration++;
8282
8828
  this.runBudgetExceeded = false;
8283
8829
  this.runBudgetExceededReason = void 0;
8284
8830
  }
8831
+ /**
8832
+ * Refuse a call whose input alone cannot fit what is left of the run budget.
8833
+ *
8834
+ * `enforceRunBudget()` below runs AFTER a call returns, which makes the caps
8835
+ * a stop rather than a pre-authorisation: the tokens are already bought by
8836
+ * the time it looks. That was tolerable while a prompt could not exceed
8837
+ * 20,000 characters, and stopped being so when that cap was removed in
8838
+ * 0.72.0 — a multi-megabyte prompt is billed in full on the very first call
8839
+ * (the complexity classifier sends the whole thing), and no ceiling
8840
+ * configured downstream can give that money back.
8841
+ *
8842
+ * Deliberately narrow, because a false refusal is worse than a late stop:
8843
+ *
8844
+ * • INPUT ONLY. What a call returns is not knowable in advance, and
8845
+ * charging a worst-case `maxTokens` of output against the budget would
8846
+ * decline runs that would have finished comfortably. Input is the part
8847
+ * that is already determined, and it is the part the removed cap used to
8848
+ * bound.
8849
+ * • Compared against what REMAINS (`cap - spent`), not the whole cap, so a
8850
+ * call is judged on the budget it can actually draw from.
8851
+ * • Skipped when the model has no usable price. An estimate cannot be made,
8852
+ * and refusing on ignorance would break every self-hosted and local model
8853
+ * the moment a cost cap is set. The post-hoc stop still applies there.
8854
+ * • Skipped entirely when no cap is configured — there is nothing to
8855
+ * pre-authorise against.
8856
+ *
8857
+ * The message names the cap AND the estimate, because "too expensive" with
8858
+ * no numbers gives the user nothing to change.
8859
+ */
8860
+ enforcePreflightBudget(model, options) {
8861
+ const budget = this.config?.budget;
8862
+ const maxCost = budget?.maxCostPerRunUsd;
8863
+ const maxTokens = budget?.maxTokensPerRun;
8864
+ if (maxCost == null && maxTokens == null) return void 0;
8865
+ const inputTokens = this.estimateInputTokens(options, model);
8866
+ if (maxTokens != null) {
8867
+ const remaining = maxTokens - this.runTokens - this.reservedTokens;
8868
+ if (inputTokens > remaining) {
8869
+ this.failPreflight(
8870
+ `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.`
8871
+ );
8872
+ }
8873
+ }
8874
+ let estimatedUsd = 0;
8875
+ if (maxCost != null) {
8876
+ const { input: inputPer1k, unknown } = resolveModelPricing(model, { inputTokens });
8877
+ if (!unknown && inputPer1k > 0) {
8878
+ estimatedUsd = inputTokens / 1e3 * inputPer1k;
8879
+ const remaining = maxCost - this.runCostUsd - this.reservedCostUsd;
8880
+ if (estimatedUsd > remaining) {
8881
+ this.failPreflight(
8882
+ `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.`
8883
+ );
8884
+ }
8885
+ }
8886
+ }
8887
+ this.reservedTokens += inputTokens;
8888
+ this.reservedCostUsd += estimatedUsd;
8889
+ const generation = this.runGeneration;
8890
+ let released = false;
8891
+ return () => {
8892
+ if (released || generation !== this.runGeneration) return;
8893
+ released = true;
8894
+ this.reservedTokens -= inputTokens;
8895
+ this.reservedCostUsd -= estimatedUsd;
8896
+ };
8897
+ }
8898
+ /**
8899
+ * What this call will send, in tokens.
8900
+ *
8901
+ * Text is the bulk of it, but not all of it: providers bill the serialized
8902
+ * TOOL DEFINITIONS on every native-tool call (Anthropic sends each name,
8903
+ * description and input schema), and an image is billed by its own size
8904
+ * rather than as the `[image]` placeholder `contentToText` reduces it to. A
8905
+ * tool-heavy or vision request could therefore pass either cap while its real
8906
+ * input already exceeded the allowance.
8907
+ */
8908
+ estimateInputTokens(options, model) {
8909
+ let tokens = guardTokens(options.systemPrompt ?? "");
8910
+ let images = 0;
8911
+ const wire = wireProfile(model.provider);
8912
+ const countsImage = (img) => wire.sendsUrlImages || img?.type === "base64";
8913
+ for (const message of options.messages) {
8914
+ if (wire.dropsMessage(message)) continue;
8915
+ tokens += TOKENS_PER_MESSAGE_FRAMING;
8916
+ if (typeof message.content === "string") {
8917
+ tokens += guardTokens(message.content);
8918
+ } else {
8919
+ switch (wire.blockHandling(message)) {
8920
+ case "blocks":
8921
+ for (const block of message.content) {
8922
+ if (!wire.sendsBlock(block)) continue;
8923
+ if (block.type === "image") {
8924
+ if (countsImage(block.image)) images++;
8925
+ continue;
8926
+ }
8927
+ tokens += guardTokens(contentToText([block]));
8928
+ }
8929
+ break;
8930
+ case "stringified":
8931
+ tokens += guardTokens(safeJson(message.content));
8932
+ break;
8933
+ }
8934
+ }
8935
+ if (message.toolCalls?.length && wire.sendsToolCalls(message)) {
8936
+ tokens += guardTokens(safeJson(wire.sizeToolCalls(message.toolCalls)));
8937
+ }
8938
+ if (message.toolCallId && message.role === "tool" && wire.sendsToolCallId) {
8939
+ tokens += guardTokens(message.toolCallId);
8940
+ }
8941
+ }
8942
+ if (options.tools?.length) {
8943
+ tokens += guardTokens(safeJson(wire.sizeTools(options.tools)));
8944
+ }
8945
+ if (wire.readsTopLevelImages) {
8946
+ const topLevel = (options.images ?? []).filter(countsImage).length;
8947
+ images += topLevel * geminiImageCopies(options.messages);
8948
+ }
8949
+ tokens += images * IMAGE_TOKENS_EACH;
8950
+ return tokens;
8951
+ }
8952
+ /**
8953
+ * Report a wait that ended because something asked it to stop, the same way
8954
+ * the provider call below reports one.
8955
+ *
8956
+ * The queue waits sit OUTSIDE the try/catch that maps aborts, so without
8957
+ * this a cancellation surfaced as whatever the queue happened to throw and
8958
+ * was retried or failed over like an ordinary error. A budget abort keeps
8959
+ * saying it ran out of budget — otherwise the reason the run stopped is lost
8960
+ * on the way up — and anything else that was aborted becomes a cancellation.
8961
+ */
8962
+ asAbortFailure(err, callSignal, runSignal) {
8963
+ if (err instanceof _CascadeRouter.BudgetExceededError) return err;
8964
+ if (err instanceof CascadeCancelledError) return err;
8965
+ if (callSignal?.aborted || runSignal?.aborted) return new CascadeCancelledError("Run cancelled");
8966
+ return err;
8967
+ }
8968
+ /** Trips the same run-level flag a post-hoc overrun does, and reports it the same way. */
8969
+ failPreflight(reason) {
8970
+ this.runBudgetExceeded = true;
8971
+ this.runBudgetExceededReason = reason;
8972
+ this.runAbort.abort(new _CascadeRouter.BudgetExceededError(reason));
8973
+ this.emit("budget:exceeded", { reason, spentUsd: this.sessionCostUsd });
8974
+ throw new _CascadeRouter.BudgetExceededError(reason);
8975
+ }
8285
8976
  /**
8286
8977
  * Enforce the hard per-task ceiling. Once tripped, the flag makes every
8287
8978
  * subsequent (and concurrent) generate() call in this run fail fast.
@@ -8297,6 +8988,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
8297
8988
  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.`;
8298
8989
  this.runBudgetExceeded = true;
8299
8990
  this.runBudgetExceededReason = reason;
8991
+ this.runAbort.abort(new _CascadeRouter.BudgetExceededError(reason));
8300
8992
  this.emit("budget:exceeded", { reason, spentUsd: this.sessionCostUsd });
8301
8993
  throw new _CascadeRouter.BudgetExceededError(reason);
8302
8994
  }
@@ -9534,6 +10226,12 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
9534
10226
  maxTokens: 4096,
9535
10227
  ...subtaskModel ? { model: subtaskModel } : {},
9536
10228
  featureTag: this.assignment?.sectionTitle,
10229
+ // The wave signal, so cancel-and-respawn reaches the request that is
10230
+ // RUNNING rather than only stopping the worker at its next checkpoint.
10231
+ // It is a superset of the run signal the router injects when this is
10232
+ // absent (T2 composes it from that signal plus its own per-wave one),
10233
+ // so wiring it here loses no cancellation and adds the wave's.
10234
+ ...this.signal ? { signal: this.signal } : {},
9537
10235
  ...this.localOnlyMatch ? { forceLocal: true } : {}
9538
10236
  };
9539
10237
  const result = await this.router.generate(
@@ -10012,6 +10710,7 @@ ${current}`
10012
10710
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
10013
10711
  maxTokens: 4096,
10014
10712
  featureTag: assignment.sectionTitle,
10713
+ ...this.signal ? { signal: this.signal } : {},
10015
10714
  ...this.localOnlyMatch ? { forceLocal: true } : {}
10016
10715
  });
10017
10716
  const next = (improved.content ?? "").trim();
@@ -10073,6 +10772,7 @@ Reply with JSON: { "completeness": "pass"|"fail", "correctness": "pass"|"fail",
10073
10772
 
10074
10773
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
10075
10774
  featureTag: assignment.sectionTitle,
10775
+ ...this.signal ? { signal: this.signal } : {},
10076
10776
  ...this.localOnlyMatch ? { forceLocal: true } : {}
10077
10777
  });
10078
10778
  try {
@@ -10112,6 +10812,7 @@ ${output.slice(0, 4e3)}`;
10112
10812
  messages: [{ role: "user", content: prompt }],
10113
10813
  maxTokens: 300,
10114
10814
  temperature: 0,
10815
+ ...this.signal ? { signal: this.signal } : {},
10115
10816
  ...this.localOnlyMatch ? { forceLocal: true } : {}
10116
10817
  });
10117
10818
  const match = /\[[\s\S]*\]/.exec(result.content);
@@ -10223,6 +10924,10 @@ Begin execution now.`;
10223
10924
  }
10224
10925
  };
10225
10926
  var PeerBus = class extends EventEmitter__default.default {
10927
+ constructor() {
10928
+ super();
10929
+ this.setMaxListeners(64);
10930
+ }
10226
10931
  outputs = /* @__PURE__ */ new Map();
10227
10932
  waiters = /* @__PURE__ */ new Map();
10228
10933
  members = /* @__PURE__ */ new Set();
@@ -11471,6 +12176,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
11471
12176
  const waveSignal = AbortSignal.any(
11472
12177
  [this.signal, this.waveAbortController.signal].filter(Boolean)
11473
12178
  );
12179
+ EventEmitter.setMaxListeners(64, waveSignal);
11474
12180
  const runOne = async (id) => {
11475
12181
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
11476
12182
  const worker = workerMap.get(id);
@@ -18471,115 +19177,6 @@ var GraphSearchTool = class extends BaseTool {
18471
19177
  }
18472
19178
  };
18473
19179
 
18474
- // src/core/context/compaction.ts
18475
- function estimateTokens(text) {
18476
- if (!text) return 0;
18477
- return Math.max(1, Math.ceil(text.length / 4));
18478
- }
18479
- function contentToText(content) {
18480
- if (typeof content === "string") return content;
18481
- return content.map((c) => c.type === "text" ? c.text : c.type === "tool_result" ? c.content : `[${c.type}]`).join(" ");
18482
- }
18483
- function messagesTokens(messages) {
18484
- return messages.reduce((n, m) => n + estimateTokens(contentToText(m.content)), 0);
18485
- }
18486
- function atomize(text, maxChars) {
18487
- const seps = ["\n\n", "\n", ". ", " "];
18488
- let units = [text];
18489
- for (const sep of seps) {
18490
- if (units.every((u) => u.length <= maxChars)) break;
18491
- const next = [];
18492
- for (const u of units) {
18493
- if (u.length <= maxChars) {
18494
- next.push(u);
18495
- continue;
18496
- }
18497
- const parts = u.split(sep);
18498
- parts.forEach((p, i) => next.push(i < parts.length - 1 ? p + sep : p));
18499
- }
18500
- units = next;
18501
- }
18502
- const out = [];
18503
- for (const u of units) {
18504
- if (u.length <= maxChars) out.push(u);
18505
- else for (let i = 0; i < u.length; i += maxChars) out.push(u.slice(i, i + maxChars));
18506
- }
18507
- return out;
18508
- }
18509
- function chunkText(text, opts) {
18510
- const maxChars = Math.max(1, Math.floor(opts.maxTokens * 4));
18511
- const overlap = Math.min(maxChars - 1, Math.max(0, Math.floor(maxChars * (opts.overlapRatio ?? 0.1))));
18512
- const trimmed = text.trim();
18513
- if (!trimmed) return [];
18514
- if (trimmed.length <= maxChars) return [trimmed];
18515
- const units = atomize(trimmed, maxChars);
18516
- const chunks = [];
18517
- let cur = "";
18518
- for (const u of units) {
18519
- if (cur && cur.length + u.length > maxChars) {
18520
- chunks.push(cur.trim());
18521
- cur = overlap > 0 ? cur.slice(-overlap) : "";
18522
- }
18523
- cur += u;
18524
- }
18525
- if (cur.trim()) chunks.push(cur.trim());
18526
- return chunks;
18527
- }
18528
- function needsCompaction(totalTokens, contextWindow, reserve = 0.2) {
18529
- if (!contextWindow || contextWindow <= 0) return false;
18530
- return totalTokens > contextWindow * (1 - reserve);
18531
- }
18532
- async function mapReduceCompact(text, opts) {
18533
- const capChars = opts.capTokens * 4;
18534
- let working = text;
18535
- let truncated = false;
18536
- if (working.length > capChars) {
18537
- working = working.slice(0, capChars);
18538
- truncated = true;
18539
- }
18540
- const instruction = opts.instruction ?? "Summarize this content, preserving key facts, names, numbers, and decisions.";
18541
- let chunks = chunkText(working, { maxTokens: opts.chunkTokens });
18542
- if (chunks.length <= 1) return { text: working, chunks: chunks.length, calls: 0, truncated };
18543
- let calls = 0;
18544
- let parts = await Promise.all(
18545
- chunks.map((c, i) => {
18546
- calls++;
18547
- return opts.summarize(c, `${instruction} (part ${i + 1} of ${chunks.length})`);
18548
- })
18549
- );
18550
- let combined = parts.join("\n\n");
18551
- const maxPasses = opts.maxReducePasses ?? 3;
18552
- for (let pass = 0; pass < maxPasses && estimateTokens(combined) > opts.targetTokens; pass++) {
18553
- const groups = chunkText(combined, { maxTokens: opts.chunkTokens });
18554
- if (groups.length <= 1) break;
18555
- parts = await Promise.all(
18556
- groups.map((g) => {
18557
- calls++;
18558
- return opts.summarize(g, "Merge these partial summaries into one cohesive summary; keep all distinct facts.");
18559
- })
18560
- );
18561
- combined = parts.join("\n\n");
18562
- }
18563
- const note = truncated ? "\n\n[Note: input exceeded the extended-context cap and was truncated.]" : "";
18564
- return { text: combined + note, chunks: chunks.length, calls, truncated };
18565
- }
18566
- async function rollingSummary(messages, opts) {
18567
- if (messages.length <= opts.keepRecent) return messages;
18568
- const older = messages.slice(0, messages.length - opts.keepRecent);
18569
- const recent = messages.slice(messages.length - opts.keepRecent);
18570
- const transcript = older.map((m) => `${m.role}: ${contentToText(m.content)}`).join("\n");
18571
- const summary = await opts.summarize(
18572
- transcript,
18573
- "Summarize this earlier conversation. Preserve facts, decisions, names, numbers, and any open threads or todos."
18574
- );
18575
- const summaryMessage = {
18576
- role: "system",
18577
- content: `Summary of earlier conversation (folded to fit the context window):
18578
- ${summary}`
18579
- };
18580
- return [summaryMessage, ...recent];
18581
- }
18582
-
18583
19180
  // src/core/steering/guidance.ts
18584
19181
  var GuidanceQueue = class {
18585
19182
  entries = [];
@@ -20131,7 +20728,16 @@ ${finalOutput}` : ""
20131
20728
  const selector = this.router.getSelector();
20132
20729
  const model = options.fastAnswerModel ? selector.selectForTier(tier, options.fastAnswerModel) : selector.getCandidatesForTier(tier)[0] ?? selector.selectForTier(tier);
20133
20730
  if (!model) {
20134
- throw new Error("No model is available for a fast answer \u2014 add a provider API key first.");
20731
+ const failures2 = this.router.providerProbeFailures();
20732
+ const context = failures2.length ? ` Configured providers: ${failures2.map((f) => `${f.provider}: ${f.reason}`).join("; ")}` : "";
20733
+ if (options.fastAnswerModel) {
20734
+ throw new Error(
20735
+ `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}`
20736
+ );
20737
+ }
20738
+ throw new Error(
20739
+ 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."
20740
+ );
20135
20741
  }
20136
20742
  this.recordDecision(
20137
20743
  "model",