teapot-coding-agent 0.37.59 → 0.37.61

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 (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
@@ -1,40 +1,3 @@
1
- /**
2
- * Ordered, one-way migrations for on-disk state.
3
- *
4
- * ## Why order and idempotency matter more than the transformations
5
- *
6
- * A single upgrade path is easy. The hard case is a machine that skips several releases: an
7
- * operator on v0.20 upgrading straight to v0.40 runs EVERY migration in between at once. Two
8
- * migrations that each look correct can then be wrong together — the canonical example is a future
9
- * `config.json` split combined with today's "store the session title in `agents[]`":
10
- *
11
- * split first → titles land in the new config directory, then the old file is read again and
12
- * the titles are written a second time, or to the wrong file;
13
- * titles first → the split then has to carry a field it never knew about.
14
- *
15
- * So each migration here:
16
- *
17
- * - is **idempotent** — running it twice is a no-op, because a crash mid-upgrade must be
18
- * resumable and a re-run must not double-apply;
19
- * - declares an **order** — `order` is ascending, and a later step may assume every earlier one
20
- * has run;
21
- * - never deletes data it does not fully replace (a rename, not a delete-then-write);
22
- * - reports what it did, so a boot log shows which steps actually ran.
23
- *
24
- * ## What this module is NOT
25
- *
26
- * It does not decide WHICH migrations exist — each one lives in its own file and is registered in
27
- * `index.ts`. Keeping them out of `master.ts` is the point: they were spread across the boot path,
28
- * where an ordering mistake is invisible.
29
- */
30
- /**
31
- * Run every registered migration in `order`, skipping nothing.
32
- *
33
- * There is no "already ran" gate on purpose: each step is idempotent, so the cheapest correct
34
- * policy is to always run them. A recorded version would have to be validated against a config the
35
- * operator may have hand-edited or restored from backup, and a wrong version claim is how a
36
- * migration silently does not run.
37
- */
38
1
  export async function runMigrations(migrations, ctx) {
39
2
  const applied = [];
40
3
  for (const m of [...migrations].sort((a, b) => a.order - b.order)) {
@@ -45,26 +8,8 @@ export async function runMigrations(migrations, ctx) {
45
8
  }
46
9
  }
47
10
  catch (err) {
48
- // One broken step must not block the boot: the session data is still readable, and refusing
49
- // to start would be worse than starting with one conversion unmade.
50
11
  ctx.log(`migration ${m.id} FAILED (${err.message}) — continuing without it`);
51
12
  }
52
13
  }
53
14
  return { applied };
54
15
  }
55
- /**
56
- * ## What is deliberately NOT here
57
- *
58
- * Two migrations stay in `src/agent/agent.ts` because they are per-SESSION filesystem moves that
59
- * need the agent's own paths, and they already satisfy the rules above:
60
- *
61
- * - `migrateGoalFromWorkspace` — `<workspace>/GOAL.md` → the session's goal store. Deletes the
62
- * legacy file only after a successful write, so a failure re-runs.
63
- * - `migrateMemoryToAgentScope` — a session's `memory.md` → the agent-wide one. APPENDS (the
64
- * agent-wide file may already hold other sessions' notes), then removes the source. A missing
65
- * write leaves the session copy in place.
66
- *
67
- * Moving them here would mean handing this module session paths it has no business knowing. What
68
- * matters is that they obey the same contract: idempotent, no data destroyed that was not
69
- * replaced, and ordered after the config-level steps (they run at agent start, which is later).
70
- */
@@ -1,15 +1,4 @@
1
1
  import { migrateProviderKeys } from "../master.js";
2
- /**
3
- * Fold a legacy single `apiKey` into an `apiKeys` array.
4
- *
5
- * `apiKey` is KEPT in step with `apiKeys[0]` rather than deleted: other readers still expect it (the
6
- * setup wizard, `/api/providers`, hand-edited files), and a migration that removes a field the
7
- * operator can see is one they will report as data loss.
8
- *
9
- * Idempotent because `apiKeys` is AUTHORITATIVE once present: re-running on a migrated entry
10
- * produces the same list. The single `apiKey` only SEEDS the pool when there is no list — appending
11
- * it as well would resurrect a credential the operator had already replaced.
12
- */
13
2
  export const providerKeys = {
14
3
  order: 10,
15
4
  id: "providers-api-keys",
@@ -25,8 +14,6 @@ export const providerKeys = {
25
14
  continue;
26
15
  const p = entry;
27
16
  const before = JSON.stringify(p);
28
- // ONE implementation of the folding rule, shared with the runtime normaliser — a second copy
29
- // here is how the two drift and disagree about which key is authoritative.
30
17
  const next = migrateProviderKeys(p);
31
18
  for (const k of Object.keys(p))
32
19
  delete p[k];
@@ -1,14 +1,5 @@
1
1
  import { providerKeys } from "./providers.js";
2
2
  import { flatSessionLayout } from "./sessions.js";
3
3
  import { sessionTitles } from "./session-titles.js";
4
- /**
5
- * Every migration, in application order.
6
- *
7
- * Add new ones with a HIGHER `order` than everything here, and never renumber an existing one — a
8
- * machine that already ran it would skip it under the new number.
9
- *
10
- * Ordering rule: a migration may assume every lower `order` has run. That is what makes a
11
- * multi-release jump safe. If two migrations must not be reordered later, say so in both files.
12
- */
13
4
  export const MIGRATIONS = [providerKeys, flatSessionLayout, sessionTitles];
14
5
  export { runMigrations } from "./index.js";
@@ -1,20 +1,5 @@
1
1
  import { closeSync, openSync, readSync } from "node:fs";
2
2
  import path from "node:path";
3
- /**
4
- * Derive each agent's session title ONCE and store it in `config.agents[].sessionTitle`.
5
- *
6
- * Titles used to be recomputed on every `allSessions()` — opening up to 830 session logs and
7
- * allocating a 16KB buffer for each, MEASURED at 83.8ms, 8.1MB read and ~38MB of short-lived
8
- * garbage per scan, on every `/api/sessions`. Storing it makes listing a config lookup.
9
- *
10
- * Runs ONCE PER SESSION rather than once per process: the check is `sessionTitle` on the agent's
11
- * CURRENT session, so a machine that has already been through this does no file I/O at all. That
12
- * is what makes it idempotent — and why it must come AFTER any step that could change `agents[]`.
13
- *
14
- * A session whose first prompt is a HARNESS prompt yields no title: sub-agent sessions receive only
15
- * those and are labelled from their id, so inventing one would name a child after its parent's
16
- * instruction.
17
- */
18
3
  export const sessionTitles = {
19
4
  order: 30,
20
5
  id: "agents-session-title",
@@ -27,21 +12,11 @@ export const sessionTitles = {
27
12
  const session = typeof a.session === "string" ? a.session : "";
28
13
  if (!session)
29
14
  continue;
30
- // already stored (possibly hand-edited — a chosen name must never be overwritten)
31
15
  if (typeof a.sessionTitle === "string" && a.sessionTitle.trim())
32
16
  continue;
33
17
  if (session.includes("/") || session.includes(".."))
34
- continue; // never a path segment
18
+ continue;
35
19
  const title = firstUserPromptText(path.join(ctx.dataDir, "sessions", session, "chat.jsonl"));
36
- /**
37
- * The SESSION ID is the fallback, not a marker.
38
- *
39
- * A sub-agent session receives only harness prompts, so no title can ever be derived from it —
40
- * and a session id does not change either, so there is nothing to retry for. Writing the id
41
- * makes the field ALWAYS present, which is what makes the pass idempotent without an extra
42
- * flag: the next boot sees a title and skips. The sidebar already shows the id in this case,
43
- * so nothing visible changes.
44
- */
45
20
  a.sessionTitle = title ?? session;
46
21
  changed = true;
47
22
  }
@@ -50,19 +25,6 @@ export const sessionTitles = {
50
25
  return changed;
51
26
  },
52
27
  };
53
- /**
54
- * The first USER prompt in a log, trimmed to a title. `null` when there is none.
55
- *
56
- * Bounded (256KB) and stops at the first prompt, so one enormous log cannot dominate boot. A torn
57
- * final line at a chunk boundary is skipped rather than aborting the read.
58
- */
59
- /**
60
- * ONE reusable read buffer for the whole pass.
61
- *
62
- * `Buffer.alloc` per session is what made the first boot spike: 830 sessions x 16KB of short-lived
63
- * buffer, MEASURED as heap 28.2MB → 108.5MB during `start()` (33.4MB after GC). The scan is a
64
- * single-threaded loop, so sharing one buffer is safe and removes the churn entirely.
65
- */
66
28
  let scanBuffer;
67
29
  function firstUserPromptText(file) {
68
30
  let fd;
@@ -89,12 +51,12 @@ function firstUserPromptText(file) {
89
51
  e = JSON.parse(l);
90
52
  }
91
53
  catch {
92
- continue; // torn line at a chunk boundary
54
+ continue;
93
55
  }
94
56
  if (e.type !== "prompt")
95
57
  continue;
96
58
  if (e.data?.source !== "user")
97
- return null; // a harness prompt is not a label
59
+ return null;
98
60
  return titleOf(typeof e.data.text === "string" ? e.data.text : null);
99
61
  }
100
62
  }
@@ -109,12 +71,10 @@ function firstUserPromptText(file) {
109
71
  closeSync(fd);
110
72
  }
111
73
  catch {
112
- /* already closed */
113
74
  }
114
75
  }
115
76
  }
116
77
  }
117
- /** the first usable line of a prompt, clipped — the sidebar shows one short label */
118
78
  function titleOf(text) {
119
79
  const line = (text ?? "")
120
80
  .split("\n")
@@ -1,28 +1,10 @@
1
1
  import { existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
2
2
  import path from "node:path";
3
- /**
4
- * Move sessions from the ≤0.5.0 flat layout (`<dataDir>/<id>.jsonl`) into
5
- * `<dataDir>/sessions/<id>/chat.jsonl`.
6
- *
7
- * A RENAME, not a copy-then-delete: a crash between the two would otherwise leave the history in
8
- * neither place. `rename` within one filesystem is atomic, so a re-run either finds the legacy file
9
- * (and moves it) or finds it already moved.
10
- *
11
- * Order 20: it must run before anything reads a session directory.
12
- */
13
3
  export const flatSessionLayout = {
14
4
  order: 20,
15
5
  id: "sessions-flat-layout",
16
6
  summary: "sessions/<id>.jsonl → sessions/<id>/chat.jsonl",
17
7
  run(ctx) {
18
- /**
19
- * Scan the DATA DIR, not `config.agents`.
20
- *
21
- * A legacy log can exist for an agent that is no longer in the config (archived, removed, or a
22
- * config that failed to load) — and those are exactly the histories worth rescuing. Iterating
23
- * the config also made this a no-op whenever the config listed no agents, which is how a test
24
- * with an empty agent list silently stopped exercising it.
25
- */
26
8
  let names;
27
9
  try {
28
10
  names = readdirSync(ctx.dataDir);
@@ -37,11 +19,11 @@ export const flatSessionLayout = {
37
19
  continue;
38
20
  const id = name.slice(0, -".jsonl".length);
39
21
  if (!id || id.includes("/") || id.includes(".."))
40
- continue; // never a path segment
22
+ continue;
41
23
  const legacy = path.join(ctx.dataDir, name);
42
24
  const target = path.join(root, id);
43
25
  if (existsSync(target))
44
- continue; // already migrated; the legacy file is a leftover
26
+ continue;
45
27
  mkdirSync(target, { recursive: true });
46
28
  renameSync(legacy, path.join(target, "chat.jsonl"));
47
29
  ctx.log(`${name} → sessions/${id}/chat.jsonl`);
@@ -1,180 +1,204 @@
1
1
  /**
2
- * Provider model metadata (context window sizes) with a small TTL cache.
3
- * Used to auto-populate contextWindowTokens so compaction budgets derive
4
- * from the real window instead of the safe default.
2
+ * Model capabilities, from models.dev.
3
+ *
4
+ * WHY models.dev and not the provider's own `/models`: MEASURED against the operator's four
5
+ * configured providers, only OpenRouter publishes anything about capabilities. Command Code (93
6
+ * models), Google (38) and orcarouter all return just an id, a name and a context length — no
7
+ * `modalities`, no `reasoning`, no `supported_parameters`. That is why this file used to conclude
8
+ * "cannot be known", which was WRONG: models.dev carries the same information for 232 providers,
9
+ * including `commandcode` (71 models) and `opencode` (130), keyed by the provider's own API host.
10
+ *
11
+ * The shape mirrors what Pi (and its forks) do — models.dev is the authority, `reasoning_options`
12
+ * becomes the selectable effort list, `modalities.input` becomes the accepted input kinds. Keeping a
13
+ * second parser for each provider's ad-hoc `/models` JSON was the "wasteful and a source of bugs"
14
+ * the operator called out, so it is gone.
5
15
  */
6
16
  import { providerHeaders } from "./agent/provider-headers.js";
7
- const cache = new Map();
8
- const inFlight = new Map();
9
- const TTL = 10 * 60_000;
17
+ const DB_URL = "https://models.dev/api.json";
18
+ /** the whole DB is ~6MB; it changes on the order of days, so an hour is generous and cheap. */
19
+ const TTL = 60 * 60_000;
20
+ /** a failed fetch must not re-run per agent (15s × every agent at boot was the old bug) */
21
+ const NEGATIVE_TTL = 5 * 60_000;
22
+ let db;
23
+ let inFlight;
24
+ const hostOf = (baseUrl) => {
25
+ try {
26
+ return new URL(String(baseUrl ?? "")).host.toLowerCase();
27
+ }
28
+ catch {
29
+ return "";
30
+ }
31
+ };
32
+ const fresh = (s) => !!s && Date.now() - s.at < (s.negative ? NEGATIVE_TTL : TTL);
10
33
  /**
11
- * How long a FAILED lookup is remembered.
34
+ * Find the models.dev provider for a baseUrl.
12
35
  *
13
- * A failure used to be dropped on the floor (the `catch` returned `[]` without
14
- * caching), so a dead, unauthorised, or simply slow endpoint was re-fetched for
15
- * EVERY agent pointed at it. `addAgent` awaits this twice per agent and races it
16
- * against a 1.5 s timeout, so with 493 agents across a handful of providers the
17
- * same failing request — and its timeout — repeated hundreds of times. MEASURED
18
- * at boot: 1883 ms of the 4.6 s was idle, waiting on exactly this.
36
+ * HOST FIRST, then the path as a tie-break, because both are needed:
19
37
  *
20
- * Negative results expire much sooner than positive ones: a provider coming back
21
- * up should be usable within a minute, not locked out for ten.
22
- */
23
- const NEGATIVE_TTL = 30_000;
24
- /** is this cached entry still valid? (failures expire sooner) */
25
- function fresh(entry) {
26
- return Date.now() - entry.at < (entry.negative ? NEGATIVE_TTL : TTL);
27
- }
28
- /**
29
- * The catalogue if it is ALREADY known, without starting a fetch (#143).
38
+ * - a host match is what makes `commandcode` work: FIVE config entries share
39
+ * `api.commandcode.ai`, and models.dev lists that host once;
40
+ * - a host match alone is ambiguous for `opencode.ai`, which models.dev splits into `opencode`
41
+ * (130 models, `/inference/openai/v1`) and `opencode-go` (37, `/inference/go/openai/v1`).
42
+ * MEASURED: host-only picked whichever came first and resolved the session's provider to the
43
+ * wrong catalog — 37 models, and its model not found.
30
44
  *
31
- * Boot uses this so it never waits for metadata it can do without: the context
32
- * window only tunes the compaction budget, and `supportedParameters` only gates
33
- * the reasoning-effort controls (#47). `prefetchModelLists()` starts the real
34
- * lookups in parallel, so an endpoint that answers quickly is usually warm by the
35
- * time its first agent is created, and a slow or dead one costs no boot time.
45
+ * The operator's `https://opencode.ai/zen/v1` matches NEITHER path exactly, so the path only
46
+ * disambiguates when it actually agrees; otherwise the host match stands.
36
47
  */
37
- export function peekModelList(baseUrl) {
38
- const root = baseUrl.replace(/\/+$/, "");
39
- const hit = cache.get(root);
40
- if (!hit)
41
- return undefined;
42
- if (!fresh(hit))
48
+ export function providerFor(dbProviders, baseUrl) {
49
+ const host = hostOf(baseUrl);
50
+ if (!host)
43
51
  return undefined;
44
- return hit.negative ? [] : hit.list;
52
+ const sameHost = [...dbProviders.values()].filter((p) => hostOf(p.api ?? undefined) === host);
53
+ if (sameHost.length <= 1)
54
+ return sameHost[0];
55
+ const path = (() => {
56
+ try {
57
+ return new URL(String(baseUrl ?? "")).pathname;
58
+ }
59
+ catch {
60
+ return "";
61
+ }
62
+ })();
63
+ // prefer the entry whose path is a prefix/suffix relation with ours, longest overlap wins
64
+ const scored = sameHost
65
+ .map((p) => {
66
+ const apiPath = (() => {
67
+ try {
68
+ return new URL(String(p.api ?? "")).pathname;
69
+ }
70
+ catch {
71
+ return "";
72
+ }
73
+ })();
74
+ let common = 0;
75
+ for (let i = 0; i < Math.min(apiPath.length, path.length) && apiPath[i] === path[i]; i++)
76
+ common++;
77
+ /**
78
+ * When the shared prefix is only the leading `/`, the paths disagree entirely — `zen/v1` is
79
+ * neither `inference/openai/v1` nor `inference/go/openai/v1`. In that case prefer the entry
80
+ * with MORE models: a real provider catalog is the general one, and the narrower variant is a
81
+ * strict subset (opencode: 130 vs opencode-go: 37, MEASURED).
82
+ */
83
+ const models = Object.keys(p.models ?? {}).length;
84
+ return { p, common, models };
85
+ })
86
+ .sort((a, b) => b.common - a.common || b.models - a.models);
87
+ return scored[0]?.p ?? sameHost[0];
45
88
  }
46
- /** Fetch GET /models from an OpenAI-compatible endpoint (cached ~10 min). */
47
- export async function fetchModelList(baseUrl, apiKey) {
48
- const root = baseUrl.replace(/\/+$/, "");
49
- const hit = cache.get(root);
50
- if (hit && fresh(hit))
51
- return hit.list;
52
- // An in-flight lookup is SHARED: without this, two callers racing for the
53
- // same endpoint each paid the full timeout, and `newSession` in ACP mode
54
- // blocked on it before it could even reply to the editor (#15).
55
- const inflight = inFlight.get(root);
56
- if (inflight)
57
- return inflight;
89
+ async function loadDb() {
90
+ if (fresh(db))
91
+ return db;
92
+ if (inFlight)
93
+ return inFlight;
58
94
  const p = (async () => {
59
95
  try {
60
- const res = await fetch(`${root}/models`, {
61
- headers: {
62
- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
63
- ...providerHeaders(baseUrl), // OpenRouter app attribution
64
- },
65
- // boot-time calls happen per agent; a slow/unroutable endpoint used to
66
- // stall startup for up to 15s PER AGENT before this was tightened
67
- signal: AbortSignal.timeout(3_000),
96
+ const res = await fetch(DB_URL, {
97
+ headers: providerHeaders(DB_URL),
98
+ signal: AbortSignal.timeout(8_000),
68
99
  });
69
100
  if (!res.ok)
70
101
  throw new Error(`HTTP ${res.status}`);
71
102
  const j = (await res.json());
72
- const list = (j.data ?? [])
73
- .map((m) => ({
74
- id: typeof m.id === "string" ? m.id : "",
75
- contextLength: typeof m.context_length === "number" ? m.context_length : undefined,
76
- // OpenRouter-style USD per token (e.g. "0.000003") — powers the
77
- // runtime cost estimate. Providers without pricing stay undefined.
78
- pricing: m.pricing && (m.pricing.prompt !== undefined || m.pricing.completion !== undefined)
79
- ? {
80
- prompt: Number(m.pricing.prompt ?? 0),
81
- completion: Number(m.pricing.completion ?? 0),
82
- }
83
- : undefined,
84
- /**
85
- * `modalities` is only known when the provider PUBLISHES it — in practice OpenRouter's
86
- * `architecture.input_modalities`.
87
- *
88
- * Every other endpoint omits the field, so this stays `undefined` and the UI must treat it as
89
- * UNKNOWN rather than as "text only". Reported as "Command Code GOATのモデルカタログが
90
- * input/outputのフォーマットを適切に提供していない? OpenRouterしかちゃんと対応できてない可能性は
91
- * ある" — which is exactly right, and the code was right not to invent a value.
92
- *
93
- * The consequence to know about: without a modality list nothing can decide "this model
94
- * accepts an image", so an operator's screenshot reaches the model as a FILE PATH and an agent
95
- * that tries `read_file` gets bytes. The guard for that lives in `read_file` (it refuses a
96
- * binary file by name instead of returning mojibake), so the failure is at least legible.
97
- */
98
- modalities: Array.isArray(m.architecture?.input_modalities) && m.architecture.input_modalities.length
99
- ? {
100
- input: m.architecture.input_modalities,
101
- output: m.architecture.output_modalities ?? ["text"],
102
- }
103
- : undefined,
104
- // OpenRouter advertises per-model capabilities; other endpoints omit
105
- // the field entirely, which leaves support "unknown" rather than false.
106
- supportedParameters: Array.isArray(m.supported_parameters)
107
- ? m.supported_parameters.filter((p) => typeof p === "string")
108
- : undefined,
109
- // per-model reasoning levels when published (OpenRouter): the UI offers
110
- // exactly these, and the request is clamped into them — offering a level the
111
- // model does not have is a control that does nothing (#150)
112
- supportedEfforts: Array.isArray(m.reasoning?.supported_efforts)
113
- ? m.reasoning.supported_efforts.filter((e) => typeof e === "string")
114
- : undefined,
115
- ...(typeof m.reasoning?.default_effort === "string"
116
- ? { defaultEffort: m.reasoning.default_effort }
117
- : {}),
118
- ...(m.reasoning?.mandatory === true ? { reasoningMandatory: true } : {}),
119
- }))
120
- .filter((m) => m.id);
121
- cache.set(root, { at: Date.now(), list });
122
- return list;
103
+ const providers = new Map();
104
+ for (const [key, value] of Object.entries(j)) {
105
+ if (value && typeof value === "object")
106
+ providers.set(key, value);
107
+ }
108
+ const snap = { at: Date.now(), providers };
109
+ db = snap;
110
+ return snap;
123
111
  }
124
112
  catch {
125
- // Remember the FAILURE (briefly). Dropping it meant every agent on this
126
- // endpoint paid the fetch — and up to the 1.5 s race timeout — again.
127
- cache.set(root, { at: Date.now(), list: [], negative: true });
128
- return []; // unreachable provider — caller treats as "unknown"
113
+ /**
114
+ * Remember the FAILURE briefly, and always return a usable snapshot.
115
+ *
116
+ * Without the negative cache every agent paid an 8s timeout at boot — the same shape as the
117
+ * old per-endpoint fetch. An empty map is not an error to the caller: it means "no data", and
118
+ * every consumer treats absent metadata as UNKNOWN rather than as "unsupported".
119
+ */
120
+ const snap = { at: Date.now(), providers: new Map(), negative: true };
121
+ db = snap;
122
+ return snap;
129
123
  }
130
124
  })();
131
- inFlight.set(root, p);
125
+ inFlight = p;
132
126
  try {
133
127
  return await p;
134
128
  }
135
129
  finally {
136
- inFlight.delete(root);
130
+ inFlight = undefined;
137
131
  }
138
132
  }
133
+ /** the effort levels a models.dev record publishes, flattened out of `reasoning_options` */
134
+ export function effortsFrom(m) {
135
+ const values = (m?.reasoning_options ?? [])
136
+ .filter((o) => o?.type === "effort")
137
+ .flatMap((o) => (Array.isArray(o.values) ? o.values : []))
138
+ .filter((v) => typeof v === "string");
139
+ return values.length ? [...new Set(values)] : undefined;
140
+ }
141
+ const strings = (v) => {
142
+ const out = (Array.isArray(v) ? v : []).filter((x) => typeof x === "string");
143
+ return out.length ? out : undefined;
144
+ };
145
+ /** one models.dev record → our shape; unknown fields stay absent, never invented */
146
+ export function metaFrom(m) {
147
+ const input = strings(m.modalities?.input);
148
+ const output = strings(m.modalities?.output);
149
+ const efforts = effortsFrom(m);
150
+ return {
151
+ id: String(m.id ?? ""),
152
+ ...(typeof m.name === "string" ? { name: m.name } : {}),
153
+ ...(typeof m.limit?.context === "number" ? { contextLength: m.limit.context } : {}),
154
+ ...(m.cost && (typeof m.cost.input === "number" || typeof m.cost.output === "number")
155
+ ? { pricing: { prompt: Number(m.cost.input ?? 0), completion: Number(m.cost.output ?? 0) } }
156
+ : {}),
157
+ ...(input ? { modalities: { input, output: output ?? ["text"] } } : {}),
158
+ ...(efforts ? { supportedEfforts: efforts } : {}),
159
+ ...(m.reasoning === true ? { reasoning: true } : {}),
160
+ };
161
+ }
162
+ /** every model models.dev lists for this baseUrl's provider, or [] when the provider is unknown */
163
+ export async function fetchModelList(baseUrl, _apiKey) {
164
+ const snap = await loadDb();
165
+ const provider = providerFor(snap.providers, baseUrl);
166
+ if (!provider?.models)
167
+ return [];
168
+ return Object.values(provider.models).map(metaFrom).filter((m) => m.id);
169
+ }
170
+ export function peekModelList(baseUrl) {
171
+ if (!fresh(db))
172
+ return undefined;
173
+ const provider = providerFor(db.providers, baseUrl);
174
+ if (!provider?.models)
175
+ return undefined;
176
+ return Object.values(provider.models).map(metaFrom).filter((m) => m.id);
177
+ }
139
178
  export function contextLengthFor(list, model) {
140
179
  return list.find((m) => m.id === model)?.contextLength;
141
180
  }
142
- /** Reasoning-effort levels OpenRouter accepts (`reasoning_effort`). */
143
181
  export const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
144
182
  export function isReasoningEffort(v) {
145
183
  return typeof v === "string" && REASONING_EFFORTS.includes(v);
146
184
  }
147
- /** True when the endpoint is OpenRouter (the only one that documents effort). */
148
- export function isOpenRouter(baseUrl) {
149
- try {
150
- return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
151
- }
152
- catch {
153
- return false;
154
- }
155
- }
156
185
  /**
157
- * The `reasoning_effort` value to actually SEND, or undefined to omit it.
186
+ * Should the UI offer a reasoning-effort control for this model?
158
187
  *
159
- * Verified against the live OpenRouter /models endpoint (462 models): 196
160
- * advertise `reasoning_effort`, 331 the broader `reasoning`, and NONE a bare
161
- * top-level `effort`. Support is therefore far from universal, and sending the
162
- * parameter to a model that does not advertise it is how a request gets
163
- * rejected or a setting silently ignored (#47) — so it is sent only when the
164
- * endpoint is OpenRouter AND the model advertises it. Off by default: an absent
165
- * setting means "let the provider apply its own default".
188
+ * THREE answers, not two. `true` when models.dev says the model reasons; `false` when it says it
189
+ * does not; and TRUE when the model is simply not in the database — because the previous rule
190
+ * ("only OpenRouter") hid the control for every other provider even though the request would have
191
+ * worked. A wire that rejects `reasoning_effort` is recovered from at runtime
192
+ * (`isEffortUnsupported`), so an optimistic default costs one retry at worst, while a pessimistic one
193
+ * silently removes a feature the model does have.
166
194
  */
167
- export function resolveReasoningEffort(opts) {
168
- if (!isReasoningEffort(opts.effort))
169
- return undefined;
170
- if (!isOpenRouter(opts.baseUrl))
171
- return undefined;
195
+ export function effortSupportedFor(opts) {
172
196
  const meta = opts.list?.find((m) => m.id === opts.model);
173
- // No catalogue for this endpoint: we cannot verify support, so do not send.
174
- // A wrong body field is worse than an unset one.
175
- if (!meta?.supportedParameters)
176
- return undefined;
177
- if (!meta.supportedParameters.includes("reasoning_effort"))
178
- return undefined;
179
- return opts.effort;
197
+ if (!meta)
198
+ return true; // not in models.dev → unknown, not "unsupported"
199
+ return meta.reasoning === true || !!meta.supportedEfforts?.length;
200
+ }
201
+ /** the effort levels to offer, or undefined to let the caller use its default ladder */
202
+ export function effortsFor(opts) {
203
+ return opts.list?.find((m) => m.id === opts.model)?.supportedEfforts;
180
204
  }
@@ -1,20 +1,13 @@
1
- /**
2
- * Minimal 5-field cron matcher (minute hour day-of-month month day-of-week).
3
- * Also supports shorthand "every <n>s|m|h".
4
- * The master ticks once every 15s; matching is a handful of integer compares.
5
- */
6
1
  const BOUNDS = [
7
2
  [0, 59],
8
3
  [0, 23],
9
4
  [1, 31],
10
5
  [1, 12],
11
- [0, 7], // 0 and 7 are both Sunday
6
+ [0, 7],
12
7
  ];
13
8
  function parseField(part, min, max) {
14
9
  const out = new Set();
15
10
  const clampOrThrow = (v) => {
16
- // accepting an out-of-range value produces a set that never matches, i.e. a
17
- // cron that silently never fires ("99 * * * *")
18
11
  if (!Number.isInteger(v) || v < min || v > max)
19
12
  throw new Error(`bad cron field: ${part} (${min}-${max} expected)`);
20
13
  return v;
@@ -73,12 +66,10 @@ export function parseSchedule(spec) {
73
66
  fields: parts.map((p, i) => parseField(p, BOUNDS[i][0], BOUNDS[i][1])),
74
67
  };
75
68
  }
76
- /** Does this schedule fire at the given Date? */
77
69
  export function matches(schedule, d) {
78
70
  const values = [d.getMinutes(), d.getHours(), d.getDate(), d.getMonth() + 1, d.getDay()];
79
71
  for (let i = 0; i < 5; i++) {
80
72
  if (!schedule.fields[i].has(values[i])) {
81
- // Sunday alias: field 5 with 7 should also match day=0
82
73
  if (i === 4 && values[i] === 0 && schedule.fields[i].has(7))
83
74
  continue;
84
75
  return false;
@@ -86,14 +77,8 @@ export function matches(schedule, d) {
86
77
  }
87
78
  return true;
88
79
  }
89
- /**
90
- * Next fire time after `from`, scanning minute by minute (cheap: a handful of
91
- * integer compares per minute). The horizon is a year so an annual cron (a
92
- * December date seen in August) still resolves; a Feb-29-only cron can
93
- * legitimately report null most of the year.
94
- */
95
80
  export function nextFireAt(schedule, from = new Date()) {
96
- const startMinute = Math.floor(from.getTime() / 60_000) + 1; // next whole minute
81
+ const startMinute = Math.floor(from.getTime() / 60_000) + 1;
97
82
  const horizon = 60 * 24 * 366;
98
83
  for (let i = 0; i < horizon; i++) {
99
84
  const d = new Date((startMinute + i) * 60_000);