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.
- package/dist/agent-failures.d.ts +17 -0
- package/dist/agent-failures.js +151 -0
- package/dist/agent.d.ts +26 -0
- package/dist/agent.js +317 -74
- package/dist/native/loop.js +35 -0
- package/dist/native/openrouter.d.ts +7 -0
- package/dist/native/openrouter.js +25 -1
- package/dist/native/types.d.ts +2 -0
- package/dist/native/types.js +0 -38
- package/dist/openrouter-catalog.d.ts +28 -0
- package/dist/openrouter-catalog.js +299 -0
- package/dist/shell/assets/{index-C9Zhmien.js → index-BOgm5T3W.js} +60 -58
- package/dist/shell/assets/{index-BMkQt27u.css → index-DonnH--m.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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) {
|
package/dist/native/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/native/types.js
CHANGED
|
@@ -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
|
+
}
|