castle-web-cli 0.4.83 → 0.4.84

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.
@@ -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
+ }