castle-web-cli 0.4.83 → 0.4.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/agent-failures.d.ts +17 -0
  2. package/dist/agent-failures.js +151 -0
  3. package/dist/agent.d.ts +26 -0
  4. package/dist/agent.js +317 -74
  5. package/dist/ide.js +35 -13
  6. package/dist/native/loop.js +35 -0
  7. package/dist/native/openrouter.d.ts +7 -0
  8. package/dist/native/openrouter.js +25 -1
  9. package/dist/native/types.d.ts +2 -0
  10. package/dist/native/types.js +0 -38
  11. package/dist/openrouter-catalog.d.ts +28 -0
  12. package/dist/openrouter-catalog.js +299 -0
  13. package/dist/shell/assets/{index-C9Zhmien.js → index-BJLaUTJE.js} +60 -58
  14. package/dist/shell/assets/{index-BMkQt27u.css → index-DonnH--m.css} +1 -1
  15. package/dist/shell/index.html +2 -2
  16. package/kits/physics-2d/.prettierrc +8 -0
  17. package/kits/physics-2d/CLAUDE.md +329 -0
  18. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  19. package/kits/physics-2d/behaviors/Collider.jsx +199 -0
  20. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  21. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  22. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  23. package/kits/physics-2d/behaviors/tint.js +47 -0
  24. package/kits/physics-2d/blueprints/ball.scene +14 -0
  25. package/kits/physics-2d/blueprints/block.scene +12 -0
  26. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  27. package/kits/physics-2d/blueprints/crate.scene +14 -0
  28. package/kits/physics-2d/blueprints/goal.scene +12 -0
  29. package/kits/physics-2d/castle.json +13 -0
  30. package/kits/physics-2d/docs/pxart-format.md +377 -0
  31. package/kits/physics-2d/drawings/block.pxart +25 -0
  32. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  33. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  34. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  35. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  36. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  37. package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
  38. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  39. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  40. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  41. package/kits/physics-2d/editors/editorHistory.js +157 -0
  42. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  43. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  44. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  45. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  46. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  47. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  48. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  49. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  51. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  52. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  53. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  54. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  55. package/kits/physics-2d/engine/assets.js +15 -0
  56. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  57. package/kits/physics-2d/engine/blueprint.js +521 -0
  58. package/kits/physics-2d/engine/collider.js +196 -0
  59. package/kits/physics-2d/engine/files.js +117 -0
  60. package/kits/physics-2d/engine/liveReload.js +88 -0
  61. package/kits/physics-2d/engine/pxart.js +1032 -0
  62. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  63. package/kits/physics-2d/engine/scene.js +686 -0
  64. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  65. package/kits/physics-2d/engine/ui.jsx +688 -0
  66. package/kits/physics-2d/engine/ui.module.css +2287 -0
  67. package/kits/physics-2d/eslint.config.js +71 -0
  68. package/kits/physics-2d/index.html +24 -0
  69. package/kits/physics-2d/main.jsx +24 -0
  70. package/kits/physics-2d/package-lock.json +2706 -0
  71. package/kits/physics-2d/package.json +42 -0
  72. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  73. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  74. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  75. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  76. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  77. package/kits/physics-2d/physics/controls.js +79 -0
  78. package/kits/physics-2d/physics/index.js +26 -0
  79. package/kits/physics-2d/physics/matterBridge.js +126 -0
  80. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  81. package/kits/physics-2d/scenes/main.scene +12 -0
  82. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  83. package/kits/physics-2d/scripts/draw.mjs +121 -0
  84. package/kits/physics-2d/vite.config.js +1 -0
  85. package/package.json +1 -1
package/dist/ide.js CHANGED
@@ -72,12 +72,7 @@ export const PTY_WS_PATH = "/__castle/pty";
72
72
  export const FILES_API_PREFIX = "/__castle/files/";
73
73
  // Directories never surfaced in the file list / never read or written through
74
74
  // the builtin editor: VCS, deck-private state, and dependency trees.
75
- const FILES_IGNORE_DIRS = new Set([
76
- ".git",
77
- ".castle",
78
- "node_modules",
79
- "dist",
80
- ]);
75
+ const FILES_IGNORE_DIRS = new Set([".git", ".castle", "node_modules", "dist"]);
81
76
  // Fallback set of kit-owned rich-editor extensions: the known castle rich
82
77
  // content types across kit variants (.pxart in basic-2d, .drawing in older
83
78
  // kits). Only used when a kit is present but its getFileKind couldn't be parsed.
@@ -264,7 +259,9 @@ function handleFilesWrite(deckDir, req, res) {
264
259
  }
265
260
  catch (err) {
266
261
  const message = err instanceof Error ? err.message : String(err);
267
- sendJson(res, 500, { error: `Could not write ${resolved.rel}: ${message}` });
262
+ sendJson(res, 500, {
263
+ error: `Could not write ${resolved.rel}: ${message}`,
264
+ });
268
265
  }
269
266
  })();
270
267
  }
@@ -319,7 +316,9 @@ function handleFilesMkdir(deckDir, req, res) {
319
316
  }
320
317
  catch (err) {
321
318
  const message = err instanceof Error ? err.message : String(err);
322
- return sendJson(res, 500, { error: `Could not create folder ${resolved.rel}: ${message}` });
319
+ return sendJson(res, 500, {
320
+ error: `Could not create folder ${resolved.rel}: ${message}`,
321
+ });
323
322
  }
324
323
  const visiblePathAdded = ensureVisiblePath(deckDir, resolved.rel);
325
324
  sendJson(res, 200, { ok: true, path: resolved.rel, visiblePathAdded });
@@ -359,7 +358,9 @@ function handleFilesRename(deckDir, req, res) {
359
358
  // filesystem (default on macOS/Windows) `to` can "exist" only because it is
360
359
  // `from` under a different case -- a case-only rename like bounce.jsx ->
361
360
  // Bounce.jsx. Allow that by treating same-inode as not-a-collision.
362
- if (from.abs !== to.abs && fs.existsSync(to.abs) && !isSameFile(from.abs, to.abs)) {
361
+ if (from.abs !== to.abs &&
362
+ fs.existsSync(to.abs) &&
363
+ !isSameFile(from.abs, to.abs)) {
363
364
  return sendJson(res, 409, { error: `Already exists: ${to.rel}` });
364
365
  }
365
366
  try {
@@ -394,7 +395,9 @@ function handleFilesDelete(deckDir, req, res) {
394
395
  }
395
396
  catch (err) {
396
397
  const message = err instanceof Error ? err.message : String(err);
397
- sendJson(res, 500, { error: `Could not delete ${resolved.rel}: ${message}` });
398
+ sendJson(res, 500, {
399
+ error: `Could not delete ${resolved.rel}: ${message}`,
400
+ });
398
401
  }
399
402
  })();
400
403
  }
@@ -431,7 +434,7 @@ function handleFilesApi(deckDir, req, res, reqPath) {
431
434
  const url = new URL(req.url ?? "/", "http://localhost");
432
435
  const resolved = resolveDeckPath(deckDir, url.searchParams.get("path"));
433
436
  if (!resolved.ok)
434
- return sendJson(res, 400, { error: resolved.error }), true;
437
+ return (sendJson(res, 400, { error: resolved.error }), true);
435
438
  try {
436
439
  const contents = fs.readFileSync(resolved.abs, "utf8");
437
440
  sendJson(res, 200, { path: resolved.rel, contents });
@@ -457,13 +460,32 @@ function handleFilesApi(deckDir, req, res, reqPath) {
457
460
  handleFilesMkdir(deckDir, req, res);
458
461
  return true;
459
462
  }
460
- return sendJson(res, 404, { error: `Unknown files action: ${action}` }), true;
463
+ return (sendJson(res, 404, { error: `Unknown files action: ${action}` }),
464
+ true);
461
465
  }
462
466
  function defaultShell() {
463
467
  if (process.platform === "win32") {
464
468
  return { command: process.env.COMSPEC ?? "cmd.exe", args: [] };
465
469
  }
466
- return { command: process.env.SHELL ?? "/bin/zsh", args: ["-l"] };
470
+ // Pick the first shell that actually exists: $SHELL (dev), then zsh (macOS),
471
+ // then bash / sh. The cloud sandbox image is Debian-based (node:22) with no
472
+ // zsh and no $SHELL set, so a bare `/bin/zsh` fallback fails with execvp
473
+ // ENOENT and the terminal never gets a shell.
474
+ const candidates = [
475
+ process.env.SHELL,
476
+ "/bin/zsh",
477
+ "/bin/bash",
478
+ "/bin/sh",
479
+ ].filter((c) => Boolean(c));
480
+ const command = candidates.find((c) => {
481
+ try {
482
+ return fs.existsSync(c);
483
+ }
484
+ catch {
485
+ return false;
486
+ }
487
+ }) ?? "/bin/sh";
488
+ return { command, args: ["-l"] };
467
489
  }
468
490
  function ptyEnv() {
469
491
  const env = {
@@ -565,6 +565,10 @@ async function runLoop(opts, toolSchemas, log) {
565
565
  const imageLabels = new Map();
566
566
  let finalText = "";
567
567
  let totalUsage;
568
+ // Whether this run has called ANY tool, across every iteration -- not just
569
+ // the current one. Scoped to the run because the question is "did this task
570
+ // do anything at all", which one iteration can't answer.
571
+ let usedAnyTool = false;
568
572
  // Once our own timeout/external-abort fires, whatever streamChatCompletion
569
573
  // reports is moot -- the abort IS the reason for stopping, so we always
570
574
  // attribute the final error to it (rather than a network error that might
@@ -635,6 +639,9 @@ async function runLoop(opts, toolSchemas, log) {
635
639
  return {
636
640
  text: finalText,
637
641
  error: streamResult.error ?? "openrouter stream ended without a final response",
642
+ // Present only for an HTTP status streamChat could label; absent for
643
+ // a dropped connection, which stays unclassified on purpose.
644
+ failure: streamResult.failure,
638
645
  usage: totalUsage,
639
646
  playtestFrames: playtestFrameList(playtestFrames),
640
647
  crashed: streamResult.crashed,
@@ -657,12 +664,40 @@ async function runLoop(opts, toolSchemas, log) {
657
664
  : {}),
658
665
  });
659
666
  if (toolCalls.length === 0) {
667
+ // No tool calls is the normal way a run ends -- the model is done and
668
+ // signing off. But a TASK that reaches this on iteration 1, having
669
+ // never called a single tool, did no work: it chatted. That used to
670
+ // return a bare success, so the card finalized "done" at 100% with
671
+ // nothing changed -- the loudest silent failure this backend had.
672
+ //
673
+ // Only tasks: the router legitimately answers in plain text, and a
674
+ // task that used tools and THEN signs off with prose is fine.
675
+ //
676
+ // This narrows the failure class rather than closing it: a task whose
677
+ // only tool calls were reads still counts as "used a tool" here and
678
+ // can still finish having changed nothing. Detecting that needs a
679
+ // notion of which tools mutate, which is a bigger change than this.
680
+ if (opts.role === "task" && !usedAnyTool) {
681
+ return {
682
+ text: finalText,
683
+ error: "the model replied without calling any tools, so nothing was done",
684
+ failure: {
685
+ kind: "no-work",
686
+ detail: "model returned prose with no tool calls",
687
+ model: opts.model,
688
+ verbose: finalText,
689
+ },
690
+ usage: totalUsage,
691
+ playtestFrames: playtestFrameList(playtestFrames),
692
+ };
693
+ }
660
694
  return {
661
695
  text: finalText,
662
696
  usage: totalUsage,
663
697
  playtestFrames: playtestFrameList(playtestFrames),
664
698
  };
665
699
  }
700
+ usedAnyTool = true;
666
701
  messages.push({
667
702
  role: "assistant",
668
703
  content: streamResult.message.content || null,
@@ -1,3 +1,4 @@
1
+ import { type AgentFailure } from "../agent-failures.js";
1
2
  import type { NativeUsage } from "./types.js";
2
3
  export interface ORToolCall {
3
4
  id: string;
@@ -55,5 +56,11 @@ export interface StreamChatResult {
55
56
  reasoningTokens?: number;
56
57
  crashed: boolean;
57
58
  error?: string;
59
+ failure?: AgentFailure;
60
+ }
61
+ export declare class OpenrouterHttpError extends Error {
62
+ readonly status: number;
63
+ readonly body: string;
64
+ constructor(status: number, body: string);
58
65
  }
59
66
  export declare function streamChatCompletion(opts: StreamChatOpts): Promise<StreamChatResult>;
@@ -13,6 +13,7 @@
13
13
  // the model already said, which the caller (native/loop.ts) is better placed
14
14
  // to decide (or simply not do, matching today's crash-then-relaunch policy at
15
15
  // the task-attempt level).
16
+ import { failureForStatus } from "../agent-failures.js";
16
17
  // Overridable via CASTLE_OPENROUTER_URL so the QA harness (see
17
18
  // scripts/tests/agent-qa/native/fake-openrouter.mjs) can point this client at
18
19
  // a local fake server instead of the real API -- read per-call (not hoisted
@@ -48,6 +49,22 @@ function backoffMs(attempt) {
48
49
  function isRetryableStatus(status) {
49
50
  return status === 429 || status >= 500;
50
51
  }
52
+ // A non-retryable HTTP error from OpenRouter, carrying the status as a NUMBER
53
+ // rather than only baked into a message. This path knows the status
54
+ // first-hand, so classification upstream is a lookup instead of a regex over
55
+ // our own error prose (the claude-CLI path has no such luxury -- see
56
+ // classifyProviderError). `message` keeps the old "HTTP <status>: <body>"
57
+ // wording so logs and any string-matching callers read the same as before.
58
+ export class OpenrouterHttpError extends Error {
59
+ status;
60
+ body;
61
+ constructor(status, body) {
62
+ super(`HTTP ${status}${body ? `: ${body.slice(0, 300)}` : ""}`);
63
+ this.status = status;
64
+ this.body = body;
65
+ this.name = "OpenrouterHttpError";
66
+ }
67
+ }
51
68
  async function safeReadText(res) {
52
69
  try {
53
70
  return (await res.text()).trim();
@@ -93,7 +110,7 @@ async function connectWithRetry(init, opts) {
93
110
  continue;
94
111
  }
95
112
  const bodyText = await safeReadText(res);
96
- throw new Error(`HTTP ${res.status}${bodyText ? `: ${bodyText.slice(0, 300)}` : ""}`);
113
+ throw new OpenrouterHttpError(res.status, bodyText);
97
114
  }
98
115
  // Unreachable (the loop above always returns or throws), but keeps the
99
116
  // return type honest for TS.
@@ -242,6 +259,13 @@ export async function streamChatCompletion(opts) {
242
259
  message: null,
243
260
  crashed: false,
244
261
  error: `could not run openrouter: ${err instanceof Error ? err.message : String(err)}`,
262
+ // Only an HTTP error carries a status worth classifying. A network-level
263
+ // failure (DNS, refused, reset) stays unlabelled so it keeps landing in
264
+ // the pre-existing "spawn" bucket via the "could not run" prefix --
265
+ // that's already the right read: the request never got going.
266
+ failure: err instanceof OpenrouterHttpError
267
+ ? failureForStatus(err.status, err.body)
268
+ : undefined,
245
269
  };
246
270
  }
247
271
  if (!res.body) {
@@ -1,3 +1,4 @@
1
+ import type { AgentFailure } from "../agent-failures.js";
1
2
  import type { PlaytestExecutor } from "./playtest.js";
2
3
  import type { ORReasoningEffort, ORRoutingMode } from "./openrouter.js";
3
4
  export type NativeRole = "router" | "task";
@@ -37,6 +38,7 @@ export interface NativeRunOpts {
37
38
  export interface NativeRunResult {
38
39
  text: string;
39
40
  error?: string;
41
+ failure?: AgentFailure;
40
42
  usage?: NativeUsage;
41
43
  playtestFrames?: string[];
42
44
  crashed?: boolean;
@@ -1,41 +1,3 @@
1
1
  // Shapes for the native (in-process, OpenRouter-backed) agent backend --
2
2
  // see the plan at native_openrouter_agent_loop_fd7d9b3e.plan.md, "Path B".
3
- //
4
- // These MIRROR the non-exported CliRunOpts / CliRunResult / CliUsage
5
- // interfaces in cli/src/agent.ts (see runAgentCli there), which normalize
6
- // the headless `claude` / `cursor-agent` CLI backends into onDelta/onActivity/
7
- // onThinking hooks plus a { finalText, error, usage, crashed } result. This
8
- // file is a SEPARATE set of types for now (new-files-only
9
- // constraint -- agent.ts is being edited concurrently by a sibling change),
10
- // not a re-export, so field names differ in a few places. When the native
11
- // backend is wired into agent.ts, expect one of these two outcomes:
12
- //
13
- // 1. CliRunOpts/CliRunResult are widened to a shape both backends share
14
- // (command/args/parser and children become backend-specific extras), or
15
- // 2. the call site maps between the two shapes directly:
16
- // - NativeRunResult.text -> CliRunResult.finalText
17
- // - NativeRunResult.error/usage/crashed -> same names
18
- // - CliRunResult.ok <- derived as
19
- // `!result.error && !result.crashed` (no native equivalent of
20
- // a process exit code -- "ok" always follows from the other
21
- // two fields)
22
- // - CliRunOpts.children (Set<ChildProcess>) has no native
23
- // equivalent -- runAgentNative takes `signal` (AbortSignal)
24
- // instead, so a caller cancels a run the same way it would abort
25
- // any other fetch-based operation. The wiring step would create one
26
- // AbortController per run and adapt it to whatever cancellation
27
- // registry agent.ts uses for CLI children.
28
- // - CliRunOpts.command/args/parser have no native equivalent --
29
- // replaced by `model` (the free-form OpenRouter model id) and
30
- // `apiKey`.
31
- // - CliRunOpts.logPath maps to NativeRunOpts.logPath: same file
32
- // locations (tasks/<id>/log.jsonl, .castle/agent/router-log.jsonl),
33
- // same append-JSONL discipline, but the native lines are STRUCTURED
34
- // run events (init/assistant/tool_result/eviction/retry/result --
35
- // see createRunLogger in loop.ts) rather than raw CLI stream-json.
36
- //
37
- // Turns stay stateless exactly as they are today: the caller rebuilds one big
38
- // prompt string per turn (buildRouterPrompt/buildTaskPrompt) and passes it as
39
- // `prompt`; runAgentNative holds the resulting message array only for the
40
- // lifetime of that one call.
41
3
  export {};
@@ -0,0 +1,28 @@
1
+ export interface CatalogEntry {
2
+ id: string;
3
+ supportedParameters: string[];
4
+ reasoning: unknown;
5
+ }
6
+ export type ModelCheck = {
7
+ status: "ok";
8
+ } | {
9
+ status: "unknown-model";
10
+ suggestions: string[];
11
+ } | {
12
+ status: "no-tools";
13
+ } | {
14
+ status: "unavailable";
15
+ };
16
+ export declare function primeOpenrouterCatalog(): void;
17
+ export type KeyCheck = {
18
+ status: "ok";
19
+ } | {
20
+ status: "bad-key";
21
+ } | {
22
+ status: "no-credits";
23
+ } | {
24
+ status: "unavailable";
25
+ };
26
+ export declare function checkOpenrouterKey(apiKey: string): Promise<KeyCheck>;
27
+ export declare function checkOpenrouterModel(slug: string): Promise<ModelCheck>;
28
+ export declare function openrouterCatalogEntry(slug: string): Promise<CatalogEntry | null>;
@@ -0,0 +1,299 @@
1
+ // Asks OpenRouter about our own configuration BEFORE a run starts: is this
2
+ // model slug real, can it tool-call, and is this API key usable. Both answers
3
+ // come from cheap metadata endpoints, and both turn an expensive runtime
4
+ // failure into an instant one.
5
+ //
6
+ // - https://openrouter.ai/api/v1/models is public (no auth) and every entry
7
+ // carries `supported_parameters`, which contains "tools" exactly when the
8
+ // model can tool-call. That decides two failures a run would otherwise
9
+ // discover the hard way: a slug that doesn't exist, and a slug that exists
10
+ // but can never do agent work (image/audio/embedding models share the
11
+ // catalog).
12
+ // - https://openrouter.ai/api/v1/key validates the key in ~0.2s. Worth the
13
+ // call: measured against the real binary, a bad key makes the claude CLI
14
+ // retry internally for OVER TWO MINUTES before it surfaces anything.
15
+ //
16
+ // This module NEVER blocks a run on the network, and never turns "we don't
17
+ // know" into "it's bad". Every failure path resolves to "unavailable", which
18
+ // callers treat as "allow" -- OpenRouter ships models faster than any cache
19
+ // refreshes, and a free-form field that rejects a brand-new model would be
20
+ // worse than one that validates nothing.
21
+ import * as fs from "fs";
22
+ import * as os from "os";
23
+ import * as path from "path";
24
+ // Overridable for the QA battery, which runs fake endpoints. Without the cache
25
+ // override the battery would read/write the developer's real ~/.castle and its
26
+ // fall-open assertions would pass spuriously off a warm real catalog.
27
+ function modelsUrl() {
28
+ return (process.env.CASTLE_OPENROUTER_MODELS_URL ??
29
+ "https://openrouter.ai/api/v1/models");
30
+ }
31
+ function keyUrl() {
32
+ return process.env.CASTLE_OPENROUTER_KEY_URL ?? "https://openrouter.ai/api/v1/key";
33
+ }
34
+ function cachePath() {
35
+ return (process.env.CASTLE_OPENROUTER_CATALOG_CACHE ??
36
+ path.join(os.homedir(), ".castle", "openrouter-models.json"));
37
+ }
38
+ const FETCH_TIMEOUT_MS = 3_000;
39
+ // Past this the cache is refreshed, but the STALE copy is still served while
40
+ // that happens (see loadCatalog) -- staleness costs a wrong verdict on a model
41
+ // that changed in the last day, which is cheap; a blocking fetch is not.
42
+ const FRESH_MS = 24 * 60 * 60 * 1000;
43
+ const MAX_SUGGESTIONS = 3;
44
+ // Levenshtein ceiling for a "did you mean". Past ~4 edits the suggestion stops
45
+ // being a plausible typo and starts being noise.
46
+ const MAX_SUGGESTION_DISTANCE = 4;
47
+ function hasTools(entry) {
48
+ return entry.supportedParameters.includes("tools");
49
+ }
50
+ let memo = null;
51
+ // Single-flight: tasks spawn at max concurrency, and N cold pre-flights must
52
+ // not become N fetches of a ~500KB payload.
53
+ let inflight = null;
54
+ function isFresh(file) {
55
+ const age = Date.now() - file.fetchedAt;
56
+ // A negative age means the clock moved backwards (or the file was hand-
57
+ // edited); treat it as stale rather than trusting it forever.
58
+ return age >= 0 && age < FRESH_MS;
59
+ }
60
+ function parseCatalog(body) {
61
+ const data = body?.data;
62
+ if (!Array.isArray(data))
63
+ return null;
64
+ const models = [];
65
+ for (const raw of data) {
66
+ if (typeof raw?.id !== "string")
67
+ continue;
68
+ const params = Array.isArray(raw.supported_parameters)
69
+ ? raw.supported_parameters.filter((p) => typeof p === "string")
70
+ : [];
71
+ models.push({
72
+ id: raw.id,
73
+ supportedParameters: params,
74
+ reasoning: raw.reasoning ?? null,
75
+ });
76
+ }
77
+ // An empty list means the endpoint answered with something we don't
78
+ // understand -- treat it as a failure rather than caching "no models exist",
79
+ // which would reject every slug.
80
+ return models.length > 0 ? models : null;
81
+ }
82
+ async function fetchCatalog() {
83
+ const controller = new AbortController();
84
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
85
+ try {
86
+ const res = await fetch(modelsUrl(), { signal: controller.signal });
87
+ if (!res.ok)
88
+ return null;
89
+ const models = parseCatalog(await res.json());
90
+ if (!models)
91
+ return null;
92
+ return { fetchedAt: Date.now(), models };
93
+ }
94
+ catch {
95
+ // Offline, DNS failure, timeout, malformed JSON -- all the same to us.
96
+ return null;
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+ function readCache() {
103
+ try {
104
+ const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8"));
105
+ if (!Array.isArray(parsed?.models) || typeof parsed?.fetchedAt !== "number") {
106
+ return null;
107
+ }
108
+ return parsed;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ function writeCache(file) {
115
+ try {
116
+ fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
117
+ fs.writeFileSync(cachePath(), JSON.stringify(file));
118
+ }
119
+ catch {
120
+ // A cache we can't persist just means we refetch next boot. Not fatal.
121
+ }
122
+ }
123
+ async function refresh() {
124
+ inflight ??= fetchCatalog().finally(() => {
125
+ inflight = null;
126
+ });
127
+ const fetched = await inflight;
128
+ if (fetched) {
129
+ memo = fetched;
130
+ writeCache(fetched);
131
+ }
132
+ return fetched;
133
+ }
134
+ // Stale-while-revalidate: a usable copy (however old) is returned immediately
135
+ // and a refresh runs in the background. Only a cold start with no cache at all
136
+ // awaits the network, and priming at serve boot (primeOpenrouterCatalog) means
137
+ // even that lands off the critical path in practice.
138
+ async function loadCatalog() {
139
+ memo ??= readCache();
140
+ if (memo) {
141
+ if (!isFresh(memo))
142
+ void refresh();
143
+ return memo;
144
+ }
145
+ return refresh();
146
+ }
147
+ // Called at serve boot so the first pre-flight never pays for the fetch. Safe
148
+ // to ignore the result -- it only warms memo/disk.
149
+ export function primeOpenrouterCatalog() {
150
+ void loadCatalog();
151
+ }
152
+ function levenshtein(a, b) {
153
+ // Single-row DP -- the id list is ~350 entries and this runs only on a miss.
154
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
155
+ for (let i = 1; i <= a.length; i++) {
156
+ const curr = [i];
157
+ for (let j = 1; j <= b.length; j++) {
158
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
159
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
160
+ }
161
+ prev = curr;
162
+ }
163
+ return prev[b.length];
164
+ }
165
+ function suggestionsFor(slug, models) {
166
+ const scored = [];
167
+ for (const m of models) {
168
+ // A substring match ranks above any edit distance: someone who typed
169
+ // "gpt-5.6-terra" without the vendor prefix wants openai/gpt-5.6-terra,
170
+ // which is 7 edits away and would otherwise never surface.
171
+ const score = m.id.includes(slug) ? 0 : levenshtein(slug, m.id);
172
+ if (score <= MAX_SUGGESTION_DISTANCE)
173
+ scored.push({ id: m.id, score });
174
+ }
175
+ scored.sort((x, y) => x.score - y.score || x.id.localeCompare(y.id));
176
+ if (scored.length === 0)
177
+ return [];
178
+ // Keep only what's close to the BEST match, not everything under the ceiling.
179
+ // Model names in one family sit within a few edits of each other, so a
180
+ // 1-edit typo on gpt-5.6-terra also "matches" gpt-5.6-luna at 4 -- padding a
181
+ // confident answer with two wrong ones reads as a guess.
182
+ const cutoff = scored[0].score + 1;
183
+ return scored
184
+ .filter((s) => s.score <= cutoff)
185
+ .slice(0, MAX_SUGGESTIONS)
186
+ .map((s) => s.id);
187
+ }
188
+ // OpenRouter accepts routing suffixes that are NOT catalog ids of their own --
189
+ // ":nitro" (throughput) and ":floor" (price) route to a base model. Confirmed
190
+ // against the live catalog: ":free" and ":thinking" ARE listed as distinct ids,
191
+ // ":nitro"/":floor" are not. So an exact miss retries the base slug, or we'd
192
+ // reject "anthropic/claude-opus-4.8:nitro" as unknown when it's perfectly valid.
193
+ function findEntry(slug, models) {
194
+ const exact = models.find((m) => m.id === slug);
195
+ if (exact)
196
+ return exact;
197
+ const colon = slug.lastIndexOf(":");
198
+ if (colon <= 0)
199
+ return undefined;
200
+ const base = slug.slice(0, colon);
201
+ return models.find((m) => m.id === base);
202
+ }
203
+ // Verdicts are cached in memory only, never on disk: the cache key is derived
204
+ // from a live credential, and a process-lifetime cache is enough to keep this
205
+ // off the hot path (one check per serve boot per key). Short TTL so revoking a
206
+ // key or topping up credits takes effect without a restart.
207
+ const KEY_CHECK_TTL_MS = 5 * 60 * 1000;
208
+ const keyChecks = new Map();
209
+ const keyInflight = new Map();
210
+ // Cache/log handle for a key that is never itself stored or printed. Not a
211
+ // security boundary (an in-process Map already holds the real key upstream) --
212
+ // it just keeps credentials out of anything that might get dumped.
213
+ function keyHandle(apiKey) {
214
+ let h = 0;
215
+ for (let i = 0; i < apiKey.length; i++)
216
+ h = (Math.imul(h, 31) + apiKey.charCodeAt(i)) | 0;
217
+ return `k${(h >>> 0).toString(36)}`;
218
+ }
219
+ async function fetchKeyCheck(apiKey) {
220
+ const controller = new AbortController();
221
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
222
+ try {
223
+ const res = await fetch(keyUrl(), {
224
+ headers: { authorization: `Bearer ${apiKey}` },
225
+ signal: controller.signal,
226
+ });
227
+ // Only explicit status codes are trusted. The endpoint also reports
228
+ // `limit`/`usage`, and inferring exhaustion from that arithmetic is
229
+ // tempting -- but a wrong inference BLOCKS a working setup, which is the
230
+ // one outcome this whole module is built to avoid. An out-of-credit key is
231
+ // still caught at run time by the classifier; a false "no credits" here
232
+ // would be unrecoverable from the UI.
233
+ if (res.status === 401 || res.status === 403)
234
+ return { status: "bad-key" };
235
+ if (res.status === 402)
236
+ return { status: "no-credits" };
237
+ if (!res.ok)
238
+ return { status: "unavailable" };
239
+ return { status: "ok" };
240
+ }
241
+ catch {
242
+ return { status: "unavailable" };
243
+ }
244
+ finally {
245
+ clearTimeout(timer);
246
+ }
247
+ }
248
+ export async function checkOpenrouterKey(apiKey) {
249
+ if (!apiKey)
250
+ return { status: "bad-key" };
251
+ const handle = keyHandle(apiKey);
252
+ const cached = keyChecks.get(handle);
253
+ if (cached && Date.now() - cached.at < KEY_CHECK_TTL_MS)
254
+ return cached.result;
255
+ // Single-flight per key: tasks spawn concurrently and must not each probe.
256
+ const existing = keyInflight.get(handle);
257
+ if (existing)
258
+ return existing;
259
+ const p = fetchKeyCheck(apiKey)
260
+ .then((result) => {
261
+ // An "unavailable" verdict is deliberately NOT cached -- it means the
262
+ // network hiccuped, and caching it would suppress validation for the
263
+ // next 5 minutes over one dropped request.
264
+ if (result.status !== "unavailable") {
265
+ keyChecks.set(handle, { at: Date.now(), result });
266
+ }
267
+ return result;
268
+ })
269
+ .finally(() => keyInflight.delete(handle));
270
+ keyInflight.set(handle, p);
271
+ return p;
272
+ }
273
+ export async function checkOpenrouterModel(slug) {
274
+ const catalog = await loadCatalog();
275
+ if (!catalog)
276
+ return { status: "unavailable" };
277
+ // Catalog ids are all lowercase (verified against the live endpoint), so a
278
+ // case-only difference is a typo we can match through rather than reject.
279
+ const normalized = slug.trim().toLowerCase();
280
+ const entry = findEntry(normalized, catalog.models);
281
+ if (!entry) {
282
+ return {
283
+ status: "unknown-model",
284
+ suggestions: suggestionsFor(normalized, catalog.models),
285
+ };
286
+ }
287
+ return hasTools(entry) ? { status: "ok" } : { status: "no-tools" };
288
+ }
289
+ // The single catalog lookup for the rest of the CLI -- the settings popover's
290
+ // capabilities endpoint (fetchModelCaps in agent.ts) reads reasoning support
291
+ // from here rather than fetching /models a second time. Resolves null when the
292
+ // slug is unknown OR the catalog is unreachable; callers that need to tell
293
+ // those apart use checkOpenrouterModel.
294
+ export async function openrouterCatalogEntry(slug) {
295
+ const catalog = await loadCatalog();
296
+ if (!catalog)
297
+ return null;
298
+ return findEntry(slug.trim().toLowerCase(), catalog.models) ?? null;
299
+ }