cascade-ai 0.55.0 → 0.63.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.js CHANGED
@@ -7,8 +7,8 @@ import https from 'https';
7
7
  import zlib from 'zlib';
8
8
  import { Readable } from 'stream';
9
9
  import Database2 from 'better-sqlite3';
10
- import path32 from 'path';
11
- import fs29 from 'fs';
10
+ import path32, { dirname } from 'path';
11
+ import fs29, { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs';
12
12
  import os10 from 'os';
13
13
  import crypto, { randomUUID, randomBytes, webcrypto, timingSafeEqual, createHash } from 'crypto';
14
14
  import { render, Box, Text, useStdout, useApp, useInput, Static } from 'ink';
@@ -51,12 +51,6 @@ import cron from 'node-cron';
51
51
  // Cascade AI — Multi-tier AI Orchestration System
52
52
  var __defProp = Object.defineProperty;
53
53
  var __getOwnPropNames = Object.getOwnPropertyNames;
54
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
55
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
56
- }) : x)(function(x) {
57
- if (typeof require !== "undefined") return require.apply(this, arguments);
58
- throw Error('Dynamic require of "' + x + '" is not supported');
59
- });
60
54
  var __esm = (fn, res) => function __init() {
61
55
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
62
56
  };
@@ -1476,6 +1470,23 @@ var init_pricing_data = __esm({
1476
1470
  ],
1477
1471
  notes: "Google bills image output per output-token ($120 per 1M image output tokens); $0.134/image is that rate applied to one default-resolution image. Cost per image therefore varies with resolution."
1478
1472
  },
1473
+ {
1474
+ model: "gemini-2.5-flash-image",
1475
+ provider: "gemini",
1476
+ modality: "image",
1477
+ currency: "USD",
1478
+ asOf: "2026-07-27",
1479
+ source: "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image",
1480
+ provenance: "litellm-catalog",
1481
+ rates: [
1482
+ {
1483
+ unit: "per_image",
1484
+ amount: 0.039,
1485
+ variant: "default 1024px output"
1486
+ }
1487
+ ],
1488
+ notes: "Google bills image output per output-token ($30 per 1M image output tokens); a default 1024px image is 1290 output tokens, i.e. $0.039. Cost per image therefore varies with resolution."
1489
+ },
1479
1490
  {
1480
1491
  model: "imagen-4.0-generate-001",
1481
1492
  provider: "vertex",
@@ -1746,7 +1757,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1746
1757
  var init_constants = __esm({
1747
1758
  "src/constants.ts"() {
1748
1759
  init_pricing();
1749
- CASCADE_VERSION = "0.55.0";
1760
+ CASCADE_VERSION = "0.63.0";
1750
1761
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1751
1762
  CASCADE_DB_FILE = ".cascade/memory.db";
1752
1763
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -5415,6 +5426,87 @@ function mcpToolName(serverName, toolName) {
5415
5426
  function isMcpToolName(name) {
5416
5427
  return name.startsWith(MCP_TOOL_PREFIX);
5417
5428
  }
5429
+ var READ_ONLY_MCP_VERBS = ["list", "get", "search", "read", "describe", "find", "query", "fetch", "show", "view"];
5430
+ var MUTATING_MCP_TOKENS = /* @__PURE__ */ new Set([
5431
+ "create",
5432
+ "delete",
5433
+ "remove",
5434
+ "update",
5435
+ "write",
5436
+ "modify",
5437
+ "set",
5438
+ "put",
5439
+ "post",
5440
+ "patch",
5441
+ "push",
5442
+ "merge",
5443
+ "send",
5444
+ "execute",
5445
+ "exec",
5446
+ "run",
5447
+ "deploy",
5448
+ "install",
5449
+ "uninstall",
5450
+ "upload",
5451
+ "publish",
5452
+ "revoke",
5453
+ "grant",
5454
+ "approve",
5455
+ "reject",
5456
+ "cancel",
5457
+ "enable",
5458
+ "disable",
5459
+ "add",
5460
+ "insert",
5461
+ "drop",
5462
+ "truncate",
5463
+ "clear",
5464
+ "reset",
5465
+ "restart",
5466
+ "stop",
5467
+ "kill",
5468
+ "terminate",
5469
+ "rename",
5470
+ "move",
5471
+ "fork",
5472
+ "sync",
5473
+ "replace",
5474
+ "overwrite",
5475
+ "invite",
5476
+ "ban",
5477
+ "block",
5478
+ "assign",
5479
+ "lock",
5480
+ "unlock",
5481
+ "archive",
5482
+ "star",
5483
+ "comment",
5484
+ "reply",
5485
+ "submit",
5486
+ "apply",
5487
+ "trigger",
5488
+ "invoke",
5489
+ "purge",
5490
+ "migrate",
5491
+ "edit",
5492
+ "save",
5493
+ "commit"
5494
+ ]);
5495
+ function mcpTokens(rawToolName) {
5496
+ return rawToolName.split(/[_-]+/).flatMap((segment) => segment.split(/(?<=[a-z0-9])(?=[A-Z])/)).map((t) => t.toLowerCase()).filter(Boolean);
5497
+ }
5498
+ function isReadOnlyMcpToolName(rawToolName) {
5499
+ if (mcpTokens(rawToolName).some((t) => MUTATING_MCP_TOKENS.has(t))) return false;
5500
+ const lower = rawToolName.toLowerCase();
5501
+ for (const verb of READ_ONLY_MCP_VERBS) {
5502
+ if (!lower.startsWith(verb)) continue;
5503
+ const boundary = rawToolName[verb.length];
5504
+ if (boundary === void 0 || boundary === "_" || boundary === "-" || boundary >= "A" && boundary <= "Z") {
5505
+ return true;
5506
+ }
5507
+ }
5508
+ return false;
5509
+ }
5418
5510
  function uniqueMcpServerName(desired, existingNames) {
5419
5511
  const taken = new Set(existingNames.map(mcpServerPrefix));
5420
5512
  if (!taken.has(mcpServerPrefix(desired))) return desired;
@@ -6426,8 +6518,6 @@ var LocalRequestQueue = class {
6426
6518
  };
6427
6519
  }
6428
6520
  };
6429
-
6430
- // src/core/router/dead-models.ts
6431
6521
  var DEAD_MODEL_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
6432
6522
  function deadModelKey(provider, modelId) {
6433
6523
  return `${provider}:${modelId}`;
@@ -6528,16 +6618,13 @@ var DeadModelStore = class {
6528
6618
  function fileDeadModelPersistence(filePath) {
6529
6619
  return {
6530
6620
  load() {
6531
- const fs32 = __require("fs");
6532
- if (!fs32.existsSync(filePath)) return [];
6533
- const raw = JSON.parse(fs32.readFileSync(filePath, "utf-8"));
6621
+ if (!existsSync(filePath)) return [];
6622
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
6534
6623
  return Array.isArray(raw) ? raw : [];
6535
6624
  },
6536
6625
  save(records) {
6537
- const fs32 = __require("fs");
6538
- const path37 = __require("path");
6539
- fs32.mkdirSync(path37.dirname(filePath), { recursive: true });
6540
- fs32.writeFileSync(filePath, JSON.stringify(records, null, 2));
6626
+ mkdirSync(dirname(filePath), { recursive: true });
6627
+ writeFileSync(filePath, JSON.stringify(records, null, 2));
6541
6628
  }
6542
6629
  };
6543
6630
  }
@@ -6967,6 +7054,12 @@ var FAMILY_BENCHMARKS = {
6967
7054
  // Google — strongest at analysis/data and long-context.
6968
7055
  "gemini-2.5-pro": { code: 90, analysis: 93, creative: 86, data: 92 },
6969
7056
  "gemini-2.5-flash": { code: 82, analysis: 83, creative: 80, data: 82 },
7057
+ // The 2.5-lite tier scores below full 2.5-flash but above the older 2.0
7058
+ // lite tier — it exists because a naive `gemini-?2\.5-flash` regex swallows
7059
+ // "gemini-2.5-flash-lite" as a substring match (no lite variant of its own
7060
+ // to fall into), so the weaker lite model got credited with the full
7061
+ // flash's benchmark score and kept winning Cascade Auto's "best value" pick.
7062
+ "gemini-2.5-flash-lite": { code: 72, analysis: 72, creative: 74, data: 72 },
6970
7063
  "gemini-1.5-pro": { code: 82, analysis: 84, creative: 82, data: 85 },
6971
7064
  "gemini-2.0-flash": { code: 79, analysis: 80, creative: 79, data: 80 },
6972
7065
  "gemini-flash-lite": { code: 68, analysis: 68, creative: 70, data: 68 },
@@ -6999,6 +7092,11 @@ var FAMILY_MATCHERS = [
6999
7092
  [/gpt-?4o-mini/i, "gpt-4o-mini"],
7000
7093
  [/gpt-?4o/i, "gpt-4o"],
7001
7094
  [/gemini-?2\.5-pro/i, "gemini-2.5-pro"],
7095
+ // Must precede the bare `2\.5-flash` rule below: that pattern has no word
7096
+ // boundary after "flash" and matches "gemini-2.5-flash-lite" as a plain
7097
+ // substring, so the lite variant would otherwise be scored as the full
7098
+ // flash model (see the FAMILY_BENCHMARKS comment above).
7099
+ [/gemini-?2\.5-flash-lite/i, "gemini-2.5-flash-lite"],
7002
7100
  [/gemini-?2\.5-flash/i, "gemini-2.5-flash"],
7003
7101
  [/gemini-?1\.5-pro/i, "gemini-1.5-pro"],
7004
7102
  [/gemini-?2\.0-flash-lite/i, "gemini-flash-lite"],
@@ -8506,6 +8604,92 @@ function truncateForContext(text, maxChars = 12e3) {
8506
8604
  ${tail}`;
8507
8605
  }
8508
8606
 
8607
+ // src/core/router/provider-errors.ts
8608
+ var SYSTEMIC = /* @__PURE__ */ new Set([
8609
+ "rate_limit",
8610
+ "quota_exhausted",
8611
+ "auth",
8612
+ "model_unavailable"
8613
+ ]);
8614
+ function isSystemicKind(kind) {
8615
+ return SYSTEMIC.has(kind);
8616
+ }
8617
+ function statusOf(err) {
8618
+ if (!err || typeof err !== "object") return void 0;
8619
+ const e = err;
8620
+ for (const key of ["status", "statusCode", "code"]) {
8621
+ const v = e[key];
8622
+ if (typeof v === "number" && v >= 100 && v < 600) return v;
8623
+ }
8624
+ const resp = e["response"];
8625
+ if (resp && typeof resp["status"] === "number") return resp["status"];
8626
+ const inner = e["error"];
8627
+ if (inner && typeof inner["code"] === "number") return inner["code"];
8628
+ return void 0;
8629
+ }
8630
+ function messageOf(err) {
8631
+ if (err instanceof Error) return err.message;
8632
+ if (typeof err === "string") return err;
8633
+ if (err && typeof err === "object") {
8634
+ const e = err;
8635
+ if (typeof e["message"] === "string") return e["message"];
8636
+ const inner = e["error"];
8637
+ if (inner && typeof inner["message"] === "string") return inner["message"];
8638
+ try {
8639
+ return JSON.stringify(err);
8640
+ } catch {
8641
+ }
8642
+ }
8643
+ return String(err);
8644
+ }
8645
+ function classifyProviderError(err) {
8646
+ const raw = messageOf(err).trim();
8647
+ const status = statusOf(err);
8648
+ const m = raw.toLowerCase();
8649
+ const kind = (() => {
8650
+ if (status === 429) {
8651
+ return /quota|billing|credit|exceeded your current quota|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
8652
+ }
8653
+ if (status === 401 || status === 403) return "auth";
8654
+ if (status === 404) return "model_unavailable";
8655
+ if (/rate limit|too many requests|resource_exhausted/.test(m)) {
8656
+ return /quota|billing|credit|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
8657
+ }
8658
+ if (/quota|billing|insufficient_quota|insufficient credit|payment required/.test(m)) return "quota_exhausted";
8659
+ if (/api key|unauthorized|authentication|permission denied|invalid[_ ]api/.test(m)) return "auth";
8660
+ if (/model .*(not found|does not exist|unavailable)|no such model|unknown model|not supported for/.test(m)) {
8661
+ return "model_unavailable";
8662
+ }
8663
+ if (/context length|too many tokens|maximum context|prompt is too long|input too long/.test(m)) return "context_length";
8664
+ if (/safety|content filter|blocked|recitation|prohibited/.test(m)) return "content_filter";
8665
+ if (/econnreset|etimedout|enotfound|socket hang up|network|fetch failed|aborted/.test(m)) return "network";
8666
+ return "unknown";
8667
+ })();
8668
+ return { kind, systemic: isSystemicKind(kind), status, raw };
8669
+ }
8670
+ function describeProviderError(c, modelId) {
8671
+ const on = modelId ? ` on ${modelId}` : "";
8672
+ const detail = c.raw ? ` Provider said: ${c.raw}` : "";
8673
+ switch (c.kind) {
8674
+ case "rate_limit":
8675
+ return `Rate limit hit${on}. The provider is throttling requests \u2014 lower the number of parallel workers, or use a model with a higher requests-per-minute allowance.${detail}`;
8676
+ case "quota_exhausted":
8677
+ return `Quota or billing limit reached${on}. This will not recover on its own \u2014 check the provider's billing page, or switch this tier to a different provider.${detail}`;
8678
+ case "auth":
8679
+ return `Authentication failed${on}. The API key is missing, expired, or lacks access to this model \u2014 re-check the key in Settings > Providers.${detail}`;
8680
+ case "model_unavailable":
8681
+ return `Model unavailable${on}. The id may be wrong, retired, or not enabled for this API key \u2014 pick a different model for this tier.${detail}`;
8682
+ case "context_length":
8683
+ return `The prompt was too long${on}. Reduce the attached context, or use a model with a larger context window for this tier.${detail}`;
8684
+ case "content_filter":
8685
+ return `The provider's safety filter refused this request${on}.${detail}`;
8686
+ case "network":
8687
+ return `Network error reaching the provider${on}. This is usually transient.${detail}`;
8688
+ default:
8689
+ return `The model call failed${on}.${detail}`;
8690
+ }
8691
+ }
8692
+
8509
8693
  // src/core/tiers/t3-worker.ts
8510
8694
  var CriticalToolError = class extends Error {
8511
8695
  constructor(message, toolName) {
@@ -8539,8 +8723,15 @@ var KNOWN_TOOLS = [
8539
8723
  "web_search",
8540
8724
  "glob",
8541
8725
  "grep",
8542
- "web_fetch"
8726
+ "web_fetch",
8727
+ "generate_image"
8543
8728
  ];
8729
+ var PROVIDER_BACKED_TOOLS = /* @__PURE__ */ new Set([
8730
+ "generate_image",
8731
+ "generate_speech",
8732
+ "generate_video",
8733
+ "transcribe_audio"
8734
+ ]);
8544
8735
  function buildWorkerRules(has) {
8545
8736
  const canWriteFiles = has("file_write") || has("file_edit") || has("run_code");
8546
8737
  const hasAnyTool = KNOWN_TOOLS.some(has);
@@ -8550,9 +8741,22 @@ function buildWorkerRules(has) {
8550
8741
  canWriteFiles && "- If the task asks for a file or artifact, you must actually create it in the workspace, verify that it exists, and inspect it before claiming success.",
8551
8742
  has("web_search") && '- Use the "web_search" tool to find current information, documentation, news, or general web data.',
8552
8743
  has("pdf_create") && '- Use the "pdf_create" tool for PDF requests.',
8744
+ // Without this the model "writes the image" as prose — a bracketed
8745
+ // description sitting in the deck where the picture should be — even with a
8746
+ // working image model registered. Naming the reference syntax matters as
8747
+ // much as naming the tool: a generated image nobody embeds is still a
8748
+ // missing image.
8749
+ //
8750
+ // Scoped to PowerPoint/Word specifically: those are the only exporters
8751
+ // that actually embed a Markdown image reference (cloud/web/exporters.ts
8752
+ // toPptx/toDocx). PDF and plain-text/Markdown deliverables flatten a
8753
+ // reference straight to caption text — telling the model to call
8754
+ // generate_image for THOSE just pays for an image nobody ever sees.
8755
+ has("generate_image") && '- When a slide deck (PowerPoint) or Word document deliverable calls for a decorative image or illustration, you MUST call the "generate_image" tool and then reference its result using Markdown image syntax: ![description](location), where "location" is exactly the string the tool reported back. NEVER write a text placeholder or a bracketed description such as [image: a cat] in its place. Do NOT use "generate_image" for a chart, graph, or diagram that must show exact data (numbers, axes, labels) \u2014 an image model cannot guarantee those values are correct, so render that data as a Markdown table instead. For any OTHER deliverable format (PDF, plain text, code, etc.) do NOT call "generate_image" \u2014 those cannot embed the result, so describe the visual in words instead.',
8553
8756
  has("run_code") && '- Use the "run_code" tool for any file types (Excel, Zip, csv, etc.) or complex processing not covered by other tools. Always cleanup after code execution.',
8554
8757
  "- If you are not making meaningful progress, stop and escalate rather than looping or padding the response.",
8555
8758
  has("peer_message") && '- Use the "peer_message" tool to communicate with other T3 workers if your tasks have dependencies or shared state. You can send updates or wait for signals.',
8759
+ hasAnyTool && "- Only use tools directly relevant to THIS subtask. Do not reach for an unrelated connected-service action (e.g. creating, deleting, or modifying a repository, issue, or PR; sending a message) unless the subtask explicitly calls for it.",
8556
8760
  "- Return structured output that directly addresses the expected output specification."
8557
8761
  ];
8558
8762
  return `You are a T3 Worker agent in the Cascade AI system. Your job is to execute a specific subtask completely and accurately.
@@ -9086,7 +9290,9 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
9086
9290
  const durationMs = Date.now() - toolStartMs;
9087
9291
  const errMsg = err instanceof Error ? err.message : String(err);
9088
9292
  this.emit("tool:result", { id: tc.id, tierId: this.id, toolName: tc.name, error: errMsg, durationMs });
9089
- if (/\b(429|rate.?limit|authentication|api.?key|forbidden|401|403)\b/i.test(errMsg)) {
9293
+ const isTaggedSystemic = err instanceof Error && err.systemic === true;
9294
+ const isProviderBacked = PROVIDER_BACKED_TOOLS.has(tc.name);
9295
+ if (isTaggedSystemic || isProviderBacked && classifyProviderError(err).systemic) {
9090
9296
  throw new CriticalToolError(errMsg, tc.name);
9091
9297
  }
9092
9298
  return await this.adaptiveFallback(tc, `Tool error: ${errMsg}`);
@@ -9374,8 +9580,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
9374
9580
  } catch {
9375
9581
  return {
9376
9582
  checksRun: ["completeness", "correctness", "compliance"],
9377
- passed: ["completeness", "correctness", "compliance"],
9378
- failed: []
9583
+ passed: [],
9584
+ failed: ["completeness"]
9379
9585
  };
9380
9586
  }
9381
9587
  }
@@ -10165,7 +10371,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
10165
10371
  const jsonMatch = /\[[\s\S]*\]/.exec(result.content);
10166
10372
  if (!jsonMatch) throw new Error("No JSON array found");
10167
10373
  const parsed = JSON.parse(jsonMatch[0]);
10168
- return parsed.map((a) => ({ ...a, sectionTitle: assignment.sectionTitle }));
10374
+ return parsed.map((a) => ({ ...a, sectionTitle: assignment.sectionTitle, constraints: a.constraints ?? [] }));
10169
10375
  } catch {
10170
10376
  return [{
10171
10377
  subtaskId: randomUUID(),
@@ -11126,6 +11332,12 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
11126
11332
  if (!plan.sections || !Array.isArray(plan.sections) || plan.sections.length === 0) {
11127
11333
  throw new Error("Invalid plan: no sections");
11128
11334
  }
11335
+ for (const section of plan.sections) {
11336
+ section.constraints ??= [];
11337
+ for (const subtask of section.t3Subtasks ?? []) {
11338
+ subtask.constraints ??= [];
11339
+ }
11340
+ }
11129
11341
  const [min, max] = COMPLEXITY_T2_COUNT[plan.complexity] ?? [1, 8];
11130
11342
  if (plan.sections.length < min) ;
11131
11343
  }
@@ -11445,17 +11657,17 @@ var CAPABILITIES = [
11445
11657
  api: "openai-images"
11446
11658
  },
11447
11659
  {
11448
- // Imagen, not `gemini-3-pro-image`. That id appears on Google's PRICING
11449
- // page but 404s on `:predict` ("not found for API version v1beta, or is not
11450
- // supported for predict") — the Gemini image models are called through
11451
- // generateContent with an image response modality, while `:predict` is the
11452
- // Imagen shape this executor implements. A billing line item is not proof
11453
- // of a callable endpoint, which is the assumption that put the wrong id
11454
- // here in the first place.
11660
+ // Was `imagen-4.0-generate-001` on `:predict`, which now 404s for new keys
11661
+ // ("no longer available to new users") — Imagen's :predict API is deprecated
11662
+ // and shuts down 2026-08-17. Google's own migration path is this model on
11663
+ // the ordinary generateContent endpoint, so the fix was never another Imagen
11664
+ // id: the whole request/response SHAPE changes, which is why `api` moved to
11665
+ // 'gemini-generate-content' rather than just the modelId moving.
11666
+ // https://ai.google.dev/gemini-api/docs/imagen
11455
11667
  modality: "image",
11456
11668
  provider: "gemini",
11457
- modelId: "imagen-4.0-generate-001",
11458
- api: "gemini-predict"
11669
+ modelId: "gemini-2.5-flash-image",
11670
+ api: "gemini-generate-content"
11459
11671
  },
11460
11672
  // ── Speech (text → audio) ──
11461
11673
  {
@@ -11510,6 +11722,14 @@ function capabilityCost(cap) {
11510
11722
  const shown = amount < 1e-3 ? amount.toExponential(2) : amount.toFixed(4);
11511
11723
  return { label: `$${shown} ${unit}`, amount, unit };
11512
11724
  }
11725
+ function explainChoice(c, cost, modality, isBest) {
11726
+ const priced = cost ? ` ${cost.label}.` : "";
11727
+ if (c.quality !== void 0) {
11728
+ return isBest ? `${c.modelId} (${c.provider}) \u2014 ranked highest for ${modality}: ${c.qualityBasis}.${priced}` : `${c.modelId} (${c.provider}) \u2014 ranked alternative for ${modality}: ${c.qualityBasis}.${priced}`;
11729
+ }
11730
+ const noRanking = ". No published quality ranking is applied for this modality, so this is a cost choice, not a quality one.";
11731
+ return isBest ? `${c.modelId} (${c.provider}) \u2014 cheapest usable ${modality} model` + (cost ? ` at ${cost.label}` : "") + noRanking : `${c.modelId} (${c.provider}) \u2014 alternative usable ${modality} model` + (cost ? ` at ${cost.label}` : "") + noRanking;
11732
+ }
11513
11733
  var MultimodalRegistry = class {
11514
11734
  /** Provider types the user has actually configured a key for. */
11515
11735
  available;
@@ -11527,22 +11747,20 @@ var MultimodalRegistry = class {
11527
11747
  return [...new Set(this.usable().map((c) => c.modality))];
11528
11748
  }
11529
11749
  /**
11530
- * Pick a model for a modality.
11750
+ * Every usable capability for a modality, best first.
11531
11751
  *
11532
11752
  * Ranked models beat unranked ones, then higher quality, then lower unit
11533
- * price. An explicit `preferModel` wins outright when it is usable — the user
11534
- * naming a model is a stronger signal than any prior.
11753
+ * price. An explicit `preferModel` is moved to the head when it is usable —
11754
+ * the user naming a model is a stronger signal than any prior.
11755
+ *
11756
+ * `select()` is just the head of this list. The TAIL is the point: a systemic
11757
+ * provider failure (dead key, 404, exhausted quota) is scoped to one
11758
+ * provider+model, so "this model cannot draw" is not the same fact as "this
11759
+ * account cannot draw". A caller holding the ordered alternatives can try the
11760
+ * next provider instead of aborting the whole run — see generate-media.ts.
11535
11761
  */
11536
- select(modality, opts = {}) {
11537
- const pool = this.usable(modality);
11538
- if (!pool.length) return null;
11539
- if (opts.preferModel) {
11540
- const wanted = pool.find((c) => c.modelId === opts.preferModel);
11541
- if (wanted) {
11542
- return { capability: wanted, cost: capabilityCost(wanted), reason: `Requested explicitly: ${wanted.modelId}.` };
11543
- }
11544
- }
11545
- const scored = pool.map((c) => ({ c, cost: capabilityCost(c) })).sort((a, b) => {
11762
+ rank(modality, opts = {}) {
11763
+ const scored = this.usable(modality).map((c) => ({ c, cost: capabilityCost(c) })).sort((a, b) => {
11546
11764
  const qa = a.c.quality ?? -1;
11547
11765
  const qb = b.c.quality ?? -1;
11548
11766
  if (qa !== qb) return qb - qa;
@@ -11550,10 +11768,25 @@ var MultimodalRegistry = class {
11550
11768
  const pb = b.cost?.amount ?? Number.POSITIVE_INFINITY;
11551
11769
  return pa - pb;
11552
11770
  });
11553
- const best = scored[0];
11554
- const ranked = best.c.quality !== void 0;
11555
- const reason = ranked ? `${best.c.modelId} (${best.c.provider}) \u2014 ranked highest for ${modality}: ${best.c.qualityBasis}.` + (best.cost ? ` ${best.cost.label}.` : "") : `${best.c.modelId} (${best.c.provider}) \u2014 cheapest usable ${modality} model` + (best.cost ? ` at ${best.cost.label}` : "") + ". No published quality ranking is applied for this modality, so this is a cost choice, not a quality one.";
11556
- return { capability: best.c, cost: best.cost, reason };
11771
+ const results = scored.map(({ c, cost }, i) => ({
11772
+ capability: c,
11773
+ cost,
11774
+ reason: explainChoice(c, cost, modality, i === 0)
11775
+ }));
11776
+ if (opts.preferModel) {
11777
+ const i = results.findIndex((r) => r.capability.modelId === opts.preferModel);
11778
+ if (i > -1) {
11779
+ const [wanted] = results.splice(i, 1);
11780
+ results.unshift({ ...wanted, reason: `Requested explicitly: ${wanted.capability.modelId}.` });
11781
+ }
11782
+ }
11783
+ return results;
11784
+ }
11785
+ /**
11786
+ * Pick a model for a modality — the best usable one, or null if there is none.
11787
+ */
11788
+ select(modality, opts = {}) {
11789
+ return this.rank(modality, opts)[0] ?? null;
11557
11790
  }
11558
11791
  /**
11559
11792
  * A plain-language account of what this account can and cannot generate —
@@ -11606,92 +11839,6 @@ var BaseTool = class {
11606
11839
  }
11607
11840
  };
11608
11841
 
11609
- // src/core/router/provider-errors.ts
11610
- var SYSTEMIC = /* @__PURE__ */ new Set([
11611
- "rate_limit",
11612
- "quota_exhausted",
11613
- "auth",
11614
- "model_unavailable"
11615
- ]);
11616
- function isSystemicKind(kind) {
11617
- return SYSTEMIC.has(kind);
11618
- }
11619
- function statusOf(err) {
11620
- if (!err || typeof err !== "object") return void 0;
11621
- const e = err;
11622
- for (const key of ["status", "statusCode", "code"]) {
11623
- const v = e[key];
11624
- if (typeof v === "number" && v >= 100 && v < 600) return v;
11625
- }
11626
- const resp = e["response"];
11627
- if (resp && typeof resp["status"] === "number") return resp["status"];
11628
- const inner = e["error"];
11629
- if (inner && typeof inner["code"] === "number") return inner["code"];
11630
- return void 0;
11631
- }
11632
- function messageOf(err) {
11633
- if (err instanceof Error) return err.message;
11634
- if (typeof err === "string") return err;
11635
- if (err && typeof err === "object") {
11636
- const e = err;
11637
- if (typeof e["message"] === "string") return e["message"];
11638
- const inner = e["error"];
11639
- if (inner && typeof inner["message"] === "string") return inner["message"];
11640
- try {
11641
- return JSON.stringify(err);
11642
- } catch {
11643
- }
11644
- }
11645
- return String(err);
11646
- }
11647
- function classifyProviderError(err) {
11648
- const raw = messageOf(err).trim();
11649
- const status = statusOf(err);
11650
- const m = raw.toLowerCase();
11651
- const kind = (() => {
11652
- if (status === 429) {
11653
- return /quota|billing|credit|exceeded your current quota|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
11654
- }
11655
- if (status === 401 || status === 403) return "auth";
11656
- if (status === 404) return "model_unavailable";
11657
- if (/rate limit|too many requests|resource_exhausted/.test(m)) {
11658
- return /quota|billing|credit|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
11659
- }
11660
- if (/quota|billing|insufficient_quota|insufficient credit|payment required/.test(m)) return "quota_exhausted";
11661
- if (/api key|unauthorized|authentication|permission denied|invalid[_ ]api/.test(m)) return "auth";
11662
- if (/model .*(not found|does not exist|unavailable)|no such model|unknown model|not supported for/.test(m)) {
11663
- return "model_unavailable";
11664
- }
11665
- if (/context length|too many tokens|maximum context|prompt is too long|input too long/.test(m)) return "context_length";
11666
- if (/safety|content filter|blocked|recitation|prohibited/.test(m)) return "content_filter";
11667
- if (/econnreset|etimedout|enotfound|socket hang up|network|fetch failed|aborted/.test(m)) return "network";
11668
- return "unknown";
11669
- })();
11670
- return { kind, systemic: isSystemicKind(kind), status, raw };
11671
- }
11672
- function describeProviderError(c, modelId) {
11673
- const on = modelId ? ` on ${modelId}` : "";
11674
- const detail = c.raw ? ` Provider said: ${c.raw}` : "";
11675
- switch (c.kind) {
11676
- case "rate_limit":
11677
- return `Rate limit hit${on}. The provider is throttling requests \u2014 lower the number of parallel workers, or use a model with a higher requests-per-minute allowance.${detail}`;
11678
- case "quota_exhausted":
11679
- return `Quota or billing limit reached${on}. This will not recover on its own \u2014 check the provider's billing page, or switch this tier to a different provider.${detail}`;
11680
- case "auth":
11681
- return `Authentication failed${on}. The API key is missing, expired, or lacks access to this model \u2014 re-check the key in Settings > Providers.${detail}`;
11682
- case "model_unavailable":
11683
- return `Model unavailable${on}. The id may be wrong, retired, or not enabled for this API key \u2014 pick a different model for this tier.${detail}`;
11684
- case "context_length":
11685
- return `The prompt was too long${on}. Reduce the attached context, or use a model with a larger context window for this tier.${detail}`;
11686
- case "content_filter":
11687
- return `The provider's safety filter refused this request${on}.${detail}`;
11688
- case "network":
11689
- return `Network error reaching the provider${on}. This is usually transient.${detail}`;
11690
- default:
11691
- return `The model call failed${on}.${detail}`;
11692
- }
11693
- }
11694
-
11695
11842
  // src/core/multimodal/generate.ts
11696
11843
  var DEFAULT_TIMEOUT_MS = 12e4;
11697
11844
  async function callProvider(fn, modelId) {
@@ -11730,6 +11877,29 @@ function baseUrlFor(cfg, fallback) {
11730
11877
  function stamp(ext) {
11731
11878
  return `cascade-${Date.now()}.${ext}`;
11732
11879
  }
11880
+ var GEMINI_ASPECT_RATIOS = [
11881
+ ["1:1", 1 / 1],
11882
+ ["3:2", 3 / 2],
11883
+ ["2:3", 2 / 3],
11884
+ ["3:4", 3 / 4],
11885
+ ["4:3", 4 / 3],
11886
+ ["4:5", 4 / 5],
11887
+ ["5:4", 5 / 4],
11888
+ ["9:16", 9 / 16],
11889
+ ["16:9", 16 / 9],
11890
+ ["21:9", 21 / 9]
11891
+ ];
11892
+ function closestGeminiAspectRatio(size) {
11893
+ const m = size.match(/^(\d+)\s*[x×]\s*(\d+)$/i);
11894
+ if (!m) return void 0;
11895
+ const w = Number(m[1]);
11896
+ const h = Number(m[2]);
11897
+ if (!w || !h) return void 0;
11898
+ const ratio = w / h;
11899
+ return GEMINI_ASPECT_RATIOS.reduce(
11900
+ (best, cand) => Math.abs(cand[1] - ratio) < Math.abs(best[1] - ratio) ? cand : best
11901
+ )[0];
11902
+ }
11733
11903
  async function generateImage(cap, cfg, req, signal) {
11734
11904
  return callProvider(async () => {
11735
11905
  if (cap.api === "openai-images") {
@@ -11754,19 +11924,37 @@ async function generateImage(cap, cfg, req, signal) {
11754
11924
  ...first.revised_prompt ? { revisedPrompt: first.revised_prompt } : {}
11755
11925
  };
11756
11926
  }
11757
- if (cap.api === "gemini-predict") {
11927
+ if (cap.api === "gemini-generate-content") {
11758
11928
  const base = baseUrlFor(cfg, "https://generativelanguage.googleapis.com/v1beta");
11759
- const url = `${base}/models/${encodeURIComponent(cap.modelId)}:predict`;
11929
+ const url = `${base}/models/${encodeURIComponent(cap.modelId)}:generateContent`;
11930
+ const aspectRatio = req.size ? closestGeminiAspectRatio(req.size) : void 0;
11760
11931
  const res = await postJson(url, {
11761
- instances: [{ prompt: req.prompt }],
11762
- parameters: { sampleCount: 1 }
11932
+ contents: [{ parts: [{ text: req.prompt }] }],
11933
+ generationConfig: {
11934
+ // Asked for explicitly: these models can answer with text only, and a
11935
+ // text-only reply to "draw me a cat" is a failed image generation.
11936
+ responseModalities: ["IMAGE", "TEXT"],
11937
+ // Gemini has no free-form pixel size — only a named aspect ratio (see
11938
+ // closestGeminiAspectRatio). Omitted entirely when there's no
11939
+ // requested size, or no supported ratio close enough to bother with.
11940
+ ...aspectRatio ? { imageConfig: { aspectRatio } } : {}
11941
+ }
11763
11942
  }, { "x-goog-api-key": cfg.apiKey ?? "" }, signal);
11764
11943
  const body = await res.json();
11765
- const first = body.predictions?.[0];
11766
- if (!first?.bytesBase64Encoded) throw new Error("Image response contained no image data.");
11767
- const mimeType = first.mimeType ?? "image/png";
11944
+ const candidate = body.candidates?.[0];
11945
+ const parts = candidate?.content?.parts ?? [];
11946
+ const image = parts.find((p) => p.inlineData?.data)?.inlineData;
11947
+ if (!image?.data) {
11948
+ const blocked = body.promptFeedback?.blockReason ?? (candidate?.finishReason && candidate.finishReason !== "STOP" ? candidate.finishReason : void 0);
11949
+ if (blocked) throw new Error(`Image request was blocked by the provider (${blocked}).`);
11950
+ const said = parts.find((p) => p.text)?.text?.trim();
11951
+ throw new Error(
11952
+ "Image response contained no image data." + (said ? ` The model replied with text instead: ${said}` : "")
11953
+ );
11954
+ }
11955
+ const mimeType = image.mimeType ?? "image/png";
11768
11956
  return {
11769
- data: Buffer.from(first.bytesBase64Encoded, "base64"),
11957
+ data: Buffer.from(image.data, "base64"),
11770
11958
  mimeType,
11771
11959
  filename: stamp(mimeType.includes("jpeg") ? "jpg" : "png"),
11772
11960
  modelId: cap.modelId,
@@ -11919,6 +12107,49 @@ function resolve(deps, modality, preferModel) {
11919
12107
  }
11920
12108
  return { ok: true, selected, cfg };
11921
12109
  }
12110
+ function messageOf2(err) {
12111
+ return err instanceof Error ? err.message : String(err);
12112
+ }
12113
+ async function runWithProviderFallback(deps, modality, first, run, signal) {
12114
+ const failed = first.selected.capability;
12115
+ try {
12116
+ return { value: await run(failed, first.cfg), selected: first.selected };
12117
+ } catch (err) {
12118
+ if (signal?.aborted || !classifyProviderError(err).systemic) throw err;
12119
+ const tried = [`${failed.modelId} (${failed.provider}): ${messageOf2(err)}`];
12120
+ let allSystemic = true;
12121
+ let lastNonSystemic;
12122
+ const seen = /* @__PURE__ */ new Set([failed.provider]);
12123
+ for (const alt of deps.registry.rank(modality)) {
12124
+ if (seen.has(alt.capability.provider)) continue;
12125
+ const cfg = deps.lookupProvider(alt.capability.provider);
12126
+ if (!cfg) continue;
12127
+ seen.add(alt.capability.provider);
12128
+ try {
12129
+ return {
12130
+ value: await run(alt.capability, cfg),
12131
+ selected: alt,
12132
+ fellBackFrom: { capability: failed, error: messageOf2(err) }
12133
+ };
12134
+ } catch (retryErr) {
12135
+ if (signal?.aborted) throw retryErr;
12136
+ tried.push(`${alt.capability.modelId} (${alt.capability.provider}): ${messageOf2(retryErr)}`);
12137
+ if (classifyProviderError(retryErr).systemic) {
12138
+ continue;
12139
+ }
12140
+ allSystemic = false;
12141
+ lastNonSystemic = retryErr;
12142
+ }
12143
+ }
12144
+ if (tried.length === 1) throw err;
12145
+ if (!allSystemic) throw lastNonSystemic;
12146
+ const exhausted = new Error(
12147
+ `${modality} generation failed on every configured provider (${seen.size} tried). ${tried.join(" ")}`
12148
+ );
12149
+ Object.assign(exhausted, { systemic: true });
12150
+ throw exhausted;
12151
+ }
12152
+ }
11922
12153
  var GenerateImageTool = class extends BaseTool {
11923
12154
  constructor(deps) {
11924
12155
  super();
@@ -11942,16 +12173,32 @@ var GenerateImageTool = class extends BaseTool {
11942
12173
  const r = resolve(this.deps, "image", input["model"] ? String(input["model"]) : void 0);
11943
12174
  if (!r.ok) return r.error;
11944
12175
  const size = input["size"] ? String(input["size"]) : void 0;
11945
- const asset = await generateImage(
11946
- r.selected.capability,
11947
- r.cfg,
11948
- { prompt, ...size ? { size } : {} },
12176
+ const attempt = await runWithProviderFallback(
12177
+ this.deps,
12178
+ "image",
12179
+ r,
12180
+ (cap, cfg) => generateImage(cap, cfg, { prompt, ...size ? { size } : {} }, options.signal),
11949
12181
  options.signal
11950
12182
  );
12183
+ const asset = attempt.value;
12184
+ const fell = attempt.fellBackFrom;
11951
12185
  const location = await this.deps.sink(asset);
11952
12186
  return [
11953
12187
  `Image generated and saved to ${location}.`,
11954
- `Model: ${asset.modelId} \u2014 ${r.selected.reason}`,
12188
+ // Knowing WHERE the image is isn't the same as knowing how to use it: a
12189
+ // model told only "saved to X" tends to describe the picture in prose
12190
+ // instead of embedding it. Spelling out the reference syntax here makes
12191
+ // the worker's "never write a placeholder" rule concretely actionable,
12192
+ // and it works for every host because `location` is whatever that host's
12193
+ // sink returned — a workspace path on desktop/CLI, a URL in the cloud.
12194
+ `To include this image in your output, reference it as: ![description](${location})`,
12195
+ // The reported model has to be the one that actually drew the picture, not
12196
+ // the one that was picked and then failed.
12197
+ `Model: ${asset.modelId} \u2014 ${fell ? `used as a fallback after ${fell.capability.modelId} (${fell.capability.provider}) failed with a systemic error.` : attempt.selected.reason}`,
12198
+ // Worth one line: the run succeeded, so nothing else will report that a
12199
+ // configured provider is broken, and "your Gemini key is dead" is exactly
12200
+ // the kind of thing the user wants to hear once rather than never.
12201
+ fell ? `Note: ${fell.capability.provider} failed first \u2014 ${fell.error}` : "",
11955
12202
  // DALL·E rewrites the prompt before drawing; the model needs to know what
11956
12203
  // was actually rendered, not what it asked for, or its description of the
11957
12204
  // result will not match the image.
@@ -13194,9 +13441,11 @@ var WebSearchTool = class extends BaseTool {
13194
13441
  );
13195
13442
  const errors = [];
13196
13443
  let results = [];
13444
+ let anyBackendReachable = false;
13197
13445
  if (this.config.searxngUrl) {
13198
13446
  try {
13199
13447
  results = await searchSearXNG(query, this.config.searxngUrl, maxResults);
13448
+ anyBackendReachable = true;
13200
13449
  if (results.length > 0) return this.formatResults(query, results);
13201
13450
  errors.push("SearXNG: returned 0 results");
13202
13451
  } catch (err) {
@@ -13206,6 +13455,7 @@ var WebSearchTool = class extends BaseTool {
13206
13455
  if (this.config.braveApiKey) {
13207
13456
  try {
13208
13457
  results = await searchBrave(query, this.config.braveApiKey, maxResults);
13458
+ anyBackendReachable = true;
13209
13459
  if (results.length > 0) return this.formatResults(query, results);
13210
13460
  errors.push("Brave: returned 0 results");
13211
13461
  } catch (err) {
@@ -13215,6 +13465,7 @@ var WebSearchTool = class extends BaseTool {
13215
13465
  if (this.config.tavilyApiKey) {
13216
13466
  try {
13217
13467
  results = await searchTavily(query, this.config.tavilyApiKey, maxResults);
13468
+ anyBackendReachable = true;
13218
13469
  if (results.length > 0) return this.formatResults(query, results);
13219
13470
  errors.push("Tavily: returned 0 results");
13220
13471
  } catch (err) {
@@ -13224,6 +13475,7 @@ var WebSearchTool = class extends BaseTool {
13224
13475
  for (const variant of ["html", "lite"]) {
13225
13476
  try {
13226
13477
  results = await searchDuckDuckGo(query, maxResults, variant);
13478
+ anyBackendReachable = true;
13227
13479
  if (results.length > 0) return this.formatResults(query, results);
13228
13480
  errors.push(`DuckDuckGo ${variant}: returned 0 results`);
13229
13481
  } catch (err) {
@@ -13231,11 +13483,11 @@ var WebSearchTool = class extends BaseTool {
13231
13483
  }
13232
13484
  }
13233
13485
  const configHint = !this.config.searxngUrl && !this.config.braveApiKey && !this.config.tavilyApiKey ? "\nTip: Configure a search backend for better results:\n \u2022 Self-hosted: set SEARXNG_URL in your environment\n \u2022 Brave Search API: set BRAVE_SEARCH_API_KEY\n \u2022 Tavily API: set TAVILY_API_KEY" : "";
13234
- return [
13235
- `Web search for "${query}" failed across all backends:`,
13486
+ throw Object.assign(new Error([
13487
+ `Web search for "${query}" found nothing across all backends:`,
13236
13488
  ...errors.map((e) => ` \u2022 ${e}`),
13237
13489
  configHint
13238
- ].join("\n");
13490
+ ].join("\n")), { systemic: !anyBackendReachable });
13239
13491
  }
13240
13492
  formatResults(query, results) {
13241
13493
  const lines = [`Web search results for: "${query}"`, ""];
@@ -13603,6 +13855,15 @@ var McpToolWrapper = class extends BaseTool {
13603
13855
  this.description = `[MCP:${serverName}] ${description}`;
13604
13856
  this.inputSchema = inputSchema;
13605
13857
  }
13858
+ // Every built-in tool of comparable risk (shell, git, file writes, the
13859
+ // built-in github tool) overrides this to true. An MCP tool never did —
13860
+ // BaseTool's default (false) meant a connected server's create/delete/push
13861
+ // actions ran with zero human approval, whatever they did. MCP carries no
13862
+ // standard "this mutates state" flag, so default to dangerous and name the
13863
+ // exception (a read-only-looking verb) rather than the other way around.
13864
+ isDangerous() {
13865
+ return !isReadOnlyMcpToolName(this.toolName);
13866
+ }
13606
13867
  async execute(input, _options) {
13607
13868
  return this.mcpClient.callTool(this.serverName, this.toolName, input);
13608
13869
  }
@@ -13715,7 +13976,7 @@ var ToolRegistry = class extends EventEmitter {
13715
13976
  requiresApproval(toolName) {
13716
13977
  const defaults = DEFAULT_APPROVAL_REQUIRED;
13717
13978
  const configured = this.config.requireApprovalFor;
13718
- return defaults.includes(toolName) || configured.includes(toolName);
13979
+ return defaults.includes(toolName) || configured.includes(toolName) || this.isDangerous(toolName);
13719
13980
  }
13720
13981
  isDangerous(toolName) {
13721
13982
  return this.tools.get(toolName)?.isDangerous() ?? false;
@@ -16625,9 +16886,8 @@ var Cascade = class _Cascade extends EventEmitter {
16625
16886
  });
16626
16887
  this.toolRegistry = new ToolRegistry(this.config.tools, workspacePath);
16627
16888
  try {
16628
- const nodePath = __require("path");
16629
16889
  this.router.setDeadModelStore(new DeadModelStore(
16630
- fileDeadModelPersistence(nodePath.join(workspacePath, ".cascade", "dead-models.json"))
16890
+ fileDeadModelPersistence(path32.join(workspacePath, ".cascade", "dead-models.json"))
16631
16891
  ));
16632
16892
  } catch {
16633
16893
  }