@stablekernel/opencode-bifrost 0.2.1 → 0.2.3-rc.1

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/README.md CHANGED
@@ -11,6 +11,35 @@ OpenCode plugin that routes model traffic through a [Bifrost](https://docs.getbi
11
11
 
12
12
  No secret is held by the plugin; the key is read at runtime.
13
13
 
14
+ ## Datasheet supplement (off by default)
15
+
16
+ Wildcard-provider models — e.g. a fresh Fireworks launch that the CLI's
17
+ models.dev source has not cataloged yet — never reach `gateway-cli models`,
18
+ so they cannot be listed here either. When configured, this plugin also reads
19
+ the datasheet feed Bifrost itself syncs from and appends the wildcard-provider
20
+ ids the CLI list lacks; CLI-listed ids always win. The supplement applies to
21
+ the single `bifrost` provider.
22
+
23
+ Activation (first wins):
24
+
25
+ 1. env `GATEWAY_DATASHEET_URL`
26
+ 2. `~/.config/bifrost/datasheet.json` — `{"url": "..."}`
27
+ 3. `<project>/.gateway/datasheet.json` — same shape
28
+
29
+ The URL must be `https://`; a source with anything else is treated as absent.
30
+
31
+ Behavior: the feed is fetched once per hour and cached under
32
+ `~/.cache/opencode/datasheet-cache.json` (or `$XDG_CACHE_HOME`), with a 3s
33
+ hard timeout and a stale-cache fallback on failure. A failed first fetch
34
+ also triggers a background cache fill, so a slow or cold feed costs one
35
+ session rather than every start. Unconfigured or unreachable, the
36
+ supplement is silently skipped — startup never blocks on it.
37
+
38
+ Provenance: supplement-only models are tagged `limits_source: "datasheet"`
39
+ and `cost_source: "datasheet"` in the `gateway-cli models` payload they ride
40
+ in. The tags stop there — opencode's config schema has no provenance field,
41
+ so they are not forwarded into the emitted `opencode.json`.
42
+
14
43
  ## Requirements
15
44
 
16
45
  - `gateway-cli` on `PATH` (or the `GATEWAY_CLI_BIN` env var pointing at it), connected once with `gateway-cli connect`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stablekernel/opencode-bifrost",
3
- "version": "0.2.1",
3
+ "version": "0.2.3-rc.1",
4
4
  "description": "OpenCode plugin that routes models through the Bifrost gateway, resolving per-project virtual keys via gateway-cli.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,300 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import {
12
+ type FetchLike,
13
+ type SupplementalModel,
14
+ datasheetUrl,
15
+ fetchDatasheet,
16
+ mergeModels,
17
+ parseDatasheet,
18
+ } from "./datasheet.js";
19
+ import { toConfigModels, type CliModels } from "./index.js";
20
+
21
+ const glmFlash = {
22
+ provider: "fireworks_ai",
23
+ mode: "chat",
24
+ base_model: "glm-5.3-flash",
25
+ context_length: 1048576,
26
+ max_input_tokens: 1048576,
27
+ max_output_tokens: 131072,
28
+ input_cost_per_token: 0.00000015,
29
+ cache_read_input_token_cost: 0.000000029,
30
+ output_cost_per_token: 0.0000005,
31
+ supports_reasoning: true,
32
+ };
33
+
34
+ describe("parseDatasheet", () => {
35
+ test("maps a fireworks_ai chat entry, converting per-token to per-million", () => {
36
+ const out = parseDatasheet({
37
+ "fireworks_ai/accounts/fireworks/models/glm-5p3-flash": glmFlash,
38
+ });
39
+ expect(out).toHaveLength(1);
40
+ expect(out[0]).toEqual({
41
+ id: "accounts/fireworks/models/glm-5p3-flash",
42
+ name: "glm-5.3-flash",
43
+ context_window: 1048576,
44
+ max_output_tokens: 131072,
45
+ limits_source: "datasheet",
46
+ cost_source: "datasheet",
47
+ cost: {
48
+ input: 0.15,
49
+ output: 0.5,
50
+ cache_read: 0.029,
51
+ cache_write: 0,
52
+ },
53
+ reasoning: true,
54
+ } satisfies SupplementalModel);
55
+ });
56
+
57
+ test("drops non-chat entries, unknown providers, and rows without limits", () => {
58
+ const out = parseDatasheet({
59
+ "fireworks_ai/accounts/fireworks/models/qwen3-embedding-8b": {
60
+ ...glmFlash,
61
+ mode: "embedding",
62
+ },
63
+ "other_provider/some/model": glmFlash,
64
+ "fireworks_ai/accounts/fireworks/models/no-limits": {
65
+ ...glmFlash,
66
+ context_length: undefined,
67
+ },
68
+ "fireworks_ai/accounts/fireworks/models/no-max-out": {
69
+ ...glmFlash,
70
+ max_output_tokens: undefined,
71
+ },
72
+ });
73
+ expect(out).toEqual([]);
74
+ });
75
+
76
+ test("unpriced entry omits cost entirely and falls back to slug name", () => {
77
+ const out = parseDatasheet({
78
+ "fireworks_ai/accounts/fireworks/models/glm-5p3-flash": {
79
+ mode: "chat",
80
+ context_length: 1000,
81
+ max_output_tokens: 100,
82
+ },
83
+ });
84
+ expect(out[0]?.name).toBe("glm-5p3-flash");
85
+ expect(out[0]?.cost).toBeUndefined();
86
+ expect(out[0]?.cost_source).toBeUndefined();
87
+ });
88
+ });
89
+
90
+ describe("mergeModels", () => {
91
+ test("appends ids the CLI list lacks; CLI entry wins on collision", () => {
92
+ // mergeModels is generic over {id: string}: dedup is by id only, so the
93
+ // test uses plain {id, name} literals rather than full model shapes.
94
+ const cli = [
95
+ { id: "a", name: "A" },
96
+ { id: "b", name: "B" },
97
+ ];
98
+ const extra = [
99
+ { id: "b", name: "B-datasheet" },
100
+ { id: "c", name: "C" },
101
+ ];
102
+ expect(mergeModels(cli, extra)).toEqual([
103
+ { id: "a", name: "A" },
104
+ { id: "b", name: "B" },
105
+ { id: "c", name: "C" },
106
+ ]);
107
+ });
108
+ });
109
+
110
+ describe("datasheetUrl", () => {
111
+ const tmp = mkdtempSync(join(tmpdir(), "gw-ds-"));
112
+ const proj = join(tmp, "proj");
113
+ const home = join(tmp, "home");
114
+ mkdirSync(join(proj, ".gateway"), { recursive: true });
115
+ mkdirSync(join(home, ".config", "bifrost"), { recursive: true });
116
+
117
+ test("env var wins over files", () => {
118
+ expect(datasheetUrl("https://env/datasheet", home, proj)).toBe(
119
+ "https://env/datasheet",
120
+ );
121
+ });
122
+
123
+ test("user file beats project file", () => {
124
+ writeFileSync(
125
+ join(home, ".config", "bifrost", "datasheet.json"),
126
+ `{"url": "https://user/datasheet"}`,
127
+ );
128
+ writeFileSync(
129
+ join(proj, ".gateway", "datasheet.json"),
130
+ `{"url": "https://proj/datasheet"}`,
131
+ );
132
+ expect(datasheetUrl(undefined, home, proj)).toBe("https://user/datasheet");
133
+ });
134
+
135
+ test("project file used when user-level absent; null when neither", () => {
136
+ // Self-seeded: does not depend on files written by earlier tests.
137
+ writeFileSync(
138
+ join(proj, ".gateway", "datasheet.json"),
139
+ `{"url": "https://proj/datasheet"}`,
140
+ );
141
+ // a home with no datasheet.json falls through to the project file
142
+ const homeEmpty = join(tmp, "home-empty");
143
+ mkdirSync(homeEmpty, { recursive: true });
144
+ expect(datasheetUrl(undefined, homeEmpty, proj)).toBe(
145
+ "https://proj/datasheet",
146
+ );
147
+ expect(datasheetUrl(undefined, homeEmpty, join(tmp, "other"))).toBeNull();
148
+ });
149
+
150
+ test("non-https URLs are treated as absent and fall through", () => {
151
+ // Feeds steer local model listings, so every source must be TLS: an
152
+ // http:// project file is skipped, and with nothing else yielding a
153
+ // URL the result is null.
154
+ const projHttp = join(tmp, "proj-http");
155
+ mkdirSync(join(projHttp, ".gateway"), { recursive: true });
156
+ writeFileSync(
157
+ join(projHttp, ".gateway", "datasheet.json"),
158
+ `{"url": "http://insecure/datasheet"}`,
159
+ );
160
+ const homeEmpty = join(tmp, "home-empty-http");
161
+ mkdirSync(homeEmpty, { recursive: true });
162
+ expect(datasheetUrl(undefined, homeEmpty, projHttp)).toBeNull();
163
+ // A non-TLS env override likewise falls through to an https file.
164
+ writeFileSync(
165
+ join(projHttp, ".gateway", "datasheet.json"),
166
+ `{"url": "https://proj/datasheet"}`,
167
+ );
168
+ expect(
169
+ datasheetUrl("http://insecure-env/datasheet", homeEmpty, projHttp),
170
+ ).toBe("https://proj/datasheet");
171
+ });
172
+ });
173
+
174
+ describe("fetchDatasheet", () => {
175
+ const tmp = mkdtempSync(join(tmpdir(), "gw-ds-cache-"));
176
+ // Each test gets its own cache file so results never depend on run order.
177
+ const cachePath = (name: string) => join(tmp, `${name}.json`);
178
+ const ok: FetchLike = async () => ({
179
+ ok: true,
180
+ json: async () => ({
181
+ "fireworks_ai/accounts/fireworks/models/glm-5p3-flash": glmFlash,
182
+ }),
183
+ });
184
+ const fail: FetchLike = async () => ({ ok: false, json: async () => ({}) });
185
+
186
+ test("fetches, returns parsed body, writes cache", async () => {
187
+ const body = await fetchDatasheet(
188
+ "https://feed/datasheet",
189
+ cachePath("t1"),
190
+ ok,
191
+ 1000,
192
+ );
193
+ expect(
194
+ body?.["fireworks_ai/accounts/fireworks/models/glm-5p3-flash"],
195
+ ).toBeDefined();
196
+ const cached = JSON.parse(readFileSync(cachePath("t1"), "utf8"));
197
+ expect(cached.fetchedAt).toBe(1000);
198
+ });
199
+
200
+ test("fresh cache short-circuits the fetch", async () => {
201
+ // Seed a cache first so the counting call below has something to hit.
202
+ await fetchDatasheet("https://feed/datasheet", cachePath("t2"), ok, 1000);
203
+ let called = 0;
204
+ const counting: FetchLike = async () => {
205
+ called++;
206
+ return ok("u");
207
+ };
208
+ await fetchDatasheet(
209
+ "https://feed/datasheet",
210
+ cachePath("t2"),
211
+ counting,
212
+ 1000 + 1000,
213
+ );
214
+ expect(called).toBe(0);
215
+ });
216
+
217
+ test("expired cache refetches; fetch failure falls back to stale cache", async () => {
218
+ await fetchDatasheet("https://feed/datasheet", cachePath("t3"), ok, 1000);
219
+ const body = await fetchDatasheet(
220
+ "https://feed/datasheet",
221
+ cachePath("t3"),
222
+ fail,
223
+ 1000 + 3_600_000 + 1,
224
+ );
225
+ expect(
226
+ body?.["fireworks_ai/accounts/fireworks/models/glm-5p3-flash"],
227
+ ).toBeDefined();
228
+ });
229
+
230
+ test("fetch failure with no cache returns null", async () => {
231
+ expect(
232
+ await fetchDatasheet(
233
+ "https://feed/datasheet",
234
+ cachePath("absent"),
235
+ fail,
236
+ 1000,
237
+ ),
238
+ ).toBeNull();
239
+ });
240
+
241
+ test("failed fetch with no cache backfills the cache in the background", async () => {
242
+ const path = cachePath("backfill");
243
+ let calls = 0;
244
+ const flaky: FetchLike = async () => {
245
+ calls++;
246
+ if (calls === 1) throw new Error("cold start");
247
+ return {
248
+ ok: true,
249
+ json: async () => ({
250
+ "fireworks_ai/accounts/fireworks/models/glm-5p3-flash": glmFlash,
251
+ }),
252
+ };
253
+ };
254
+ const first = await fetchDatasheet(
255
+ "https://feed/datasheet",
256
+ path,
257
+ flaky,
258
+ 1000,
259
+ );
260
+ expect(first).toBeNull(); // this session skips the supplement
261
+ // the detached backfill (call 2) lands the cache for the next session
262
+ const deadline = Date.now() + 2000;
263
+ while (Date.now() < deadline) {
264
+ if (existsSync(path)) break;
265
+ await new Promise((r) => setTimeout(r, 25));
266
+ }
267
+ expect(calls).toBe(2);
268
+ const doc = JSON.parse(readFileSync(path, "utf8")) as {
269
+ fetchedAt: number;
270
+ };
271
+ expect(doc.fetchedAt).toBeGreaterThan(1000);
272
+ });
273
+ });
274
+
275
+ describe("end-to-end: CLI payload + datasheet extra -> opencode config models", () => {
276
+ test("supplemented id reaches the provider config with datasheet limits", () => {
277
+ const cliPayload: CliModels = {
278
+ models: [
279
+ {
280
+ id: "accounts/fireworks/models/glm-5p2",
281
+ name: "GLM 5.2",
282
+ context_window: 200000,
283
+ max_output_tokens: 32768,
284
+ limits_source: "gateway",
285
+ },
286
+ ],
287
+ };
288
+ const extra = parseDatasheet({
289
+ "fireworks_ai/accounts/fireworks/models/glm-5p3-flash": glmFlash,
290
+ });
291
+ const merged = mergeModels(cliPayload.models ?? [], extra);
292
+ const cfg = toConfigModels({ models: merged }, undefined);
293
+ const flash = cfg["accounts/fireworks/models/glm-5p3-flash"];
294
+ expect(flash).toBeDefined();
295
+ expect(flash?.limit?.context).toBe(1048576);
296
+ expect(flash?.limit?.output).toBe(131072);
297
+ // CLI-listed model survives the merge untouched
298
+ expect(cfg["accounts/fireworks/models/glm-5p2"]).toBeDefined();
299
+ });
300
+ });
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Datasheet supplement: a second, faster id source for wildcard-provider
3
+ * models the CLI cannot list yet.
4
+ *
5
+ * The CLI sources Fireworks (wildcard-provider) ids from models.dev, which
6
+ * lags provider launches by days and is cached 24h. Bifrost itself syncs
7
+ * from the datasheet-proxy feed hourly, so a model merged there is routable —
8
+ * this module reads the same feed and merges the ids the CLI list lacks.
9
+ * The virtual key never passes through here: the feed is public.
10
+ *
11
+ * Off by default. Activated by (first wins; the URL must be https — any
12
+ * source yielding something else is treated as absent):
13
+ * 1. env GATEWAY_DATASHEET_URL
14
+ * 2. ~/.config/bifrost/datasheet.json {"url": "<function-url>/datasheet"}
15
+ * 3. <project-root>/.gateway/datasheet.json
16
+ *
17
+ * Security note: entries only produce local picker listings and metadata;
18
+ * requests still go to the CLI-resolved (allowlisted) gateway origin. A
19
+ * hostile feed cannot redirect traffic or exfiltrate the key.
20
+ */
21
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+
24
+ /** One row of the datasheet feed (flat map keyed by "<provider>/<model-id>"). */
25
+ export type DatasheetEntry = {
26
+ provider?: string;
27
+ mode?: string;
28
+ base_model?: string;
29
+ context_length?: number;
30
+ max_input_tokens?: number;
31
+ max_output_tokens?: number;
32
+ max_tokens?: number;
33
+ input_cost_per_token?: number;
34
+ output_cost_per_token?: number;
35
+ cache_read_input_token_cost?: number;
36
+ cache_write_input_token_cost?: number;
37
+ supports_reasoning?: boolean;
38
+ };
39
+
40
+ /** A model in the same shape `gateway-cli models` emits (structural subset). */
41
+ export type SupplementalModel = {
42
+ id: string;
43
+ name: string;
44
+ context_window: number;
45
+ max_output_tokens: number;
46
+ limits_source: string;
47
+ cost_source?: string;
48
+ cost?: {
49
+ input: number;
50
+ output: number;
51
+ cache_read: number;
52
+ cache_write: number;
53
+ };
54
+ reasoning?: boolean;
55
+ };
56
+
57
+ /** Datasheet provider prefixes whose entries this module understands. */
58
+ const PROVIDER_PREFIXES = ["fireworks_ai/"];
59
+
60
+ /** How long a fetched feed is reused before refetching. */
61
+ const DATASHEET_TTL_MS = 60 * 60 * 1000;
62
+
63
+ /** Fetch hard timeout: the supplement must never block plugin init long. */
64
+ const FETCH_TIMEOUT_MS = 3000;
65
+
66
+ /** Budget for the background cache fill after a failed fetch: init is never
67
+ * blocked by it, so it can afford to outlast a cold feed start. */
68
+ const BACKFILL_TIMEOUT_MS = 15_000;
69
+
70
+ export type FetchLike = (
71
+ url: string,
72
+ init?: { signal?: AbortSignal },
73
+ ) => Promise<{ ok: boolean; status?: number; json(): Promise<unknown> }>;
74
+
75
+ type CacheDoc = { fetchedAt: number; body: Record<string, DatasheetEntry> };
76
+
77
+ /**
78
+ * Discover the feed URL: env, then user file, then project file. Only
79
+ * https:// URLs are honored; a source with anything else (including a
80
+ * non-TLS env override) falls through to the next candidate.
81
+ */
82
+ export function datasheetUrl(
83
+ env: string | undefined,
84
+ home: string | undefined,
85
+ root: string,
86
+ ): string | null {
87
+ if (env && env.startsWith("https://")) return env;
88
+ const candidates = [
89
+ home ? join(home, ".config", "bifrost", "datasheet.json") : null,
90
+ join(root, ".gateway", "datasheet.json"),
91
+ ];
92
+ for (const p of candidates) {
93
+ if (!p) continue;
94
+ try {
95
+ const parsed = JSON.parse(readFileSync(p, "utf8")) as { url?: unknown };
96
+ if (
97
+ typeof parsed.url === "string" &&
98
+ parsed.url.startsWith("https://")
99
+ ) {
100
+ return parsed.url;
101
+ }
102
+ } catch {
103
+ // absent or malformed: try the next candidate
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function readCache(path: string): CacheDoc | null {
110
+ try {
111
+ const doc = JSON.parse(readFileSync(path, "utf8")) as CacheDoc;
112
+ if (
113
+ typeof doc.fetchedAt === "number" &&
114
+ doc.body &&
115
+ typeof doc.body === "object"
116
+ ) {
117
+ return doc;
118
+ }
119
+ } catch {
120
+ // absent or malformed: treat as no cache
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function writeCache(path: string, doc: CacheDoc): void {
126
+ try {
127
+ mkdirSync(dirname(path), { recursive: true });
128
+ writeFileSync(path, JSON.stringify(doc));
129
+ } catch {
130
+ // cache write failure is non-fatal: refetch next init
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Default fetch adapter: narrows the platform Response to FetchLike's
136
+ * subset so callers can inject a mock without touching the global fetch.
137
+ */
138
+ const defaultFetch: FetchLike = async (u, i) => {
139
+ const resp = await fetch(u, i);
140
+ return { ok: resp.ok, status: resp.status, json: () => resp.json() };
141
+ };
142
+
143
+ /**
144
+ * Fetch the feed, honoring a fresh cache. On fetch failure a stale cache is
145
+ * served; with no cache at all the result is null and the caller skips the
146
+ * supplement. Garbage responses (non-object JSON, HTTP error) are treated
147
+ * as fetch failures so they never poison the cache. A fetch failure with no
148
+ * cache also triggers an unawaited backfill fetch (15s budget) that only
149
+ * writes the cache, so a cold feed costs one session, not one per start.
150
+ */
151
+ export async function fetchDatasheet(
152
+ url: string,
153
+ cachePath: string,
154
+ fetchImpl: FetchLike = defaultFetch,
155
+ now: number = Date.now(),
156
+ ): Promise<Record<string, DatasheetEntry> | null> {
157
+ const cached = readCache(cachePath);
158
+ if (cached && now - cached.fetchedAt < DATASHEET_TTL_MS) return cached.body;
159
+ try {
160
+ const resp = await fetchImpl(url, {
161
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
162
+ });
163
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
164
+ const body = (await resp.json()) as Record<string, DatasheetEntry>;
165
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
166
+ throw new Error("unexpected feed shape");
167
+ }
168
+ writeCache(cachePath, { fetchedAt: now, body });
169
+ return body;
170
+ } catch {
171
+ if (!cached) {
172
+ // Fire-and-forget warm fill: a slow feed (cold start) must not block
173
+ // plugin init, but the next session should find a warm cache. Never
174
+ // awaited; every failure path is swallowed (writeCache guards, and
175
+ // the .catch below covers the rest).
176
+ void fetchImpl(url, {
177
+ signal: AbortSignal.timeout(BACKFILL_TIMEOUT_MS),
178
+ })
179
+ .then(async (resp) => {
180
+ if (!resp.ok) return;
181
+ const body = (await resp.json()) as Record<string, DatasheetEntry>;
182
+ if (body && typeof body === "object" && !Array.isArray(body)) {
183
+ writeCache(cachePath, { fetchedAt: Date.now(), body });
184
+ }
185
+ })
186
+ .catch(() => {});
187
+ }
188
+ return cached?.body ?? null;
189
+ }
190
+ }
191
+
192
+ function perMillion(rate: number | undefined): number {
193
+ return typeof rate === "number" ? Math.round(rate * 1_000_000 * 1e8) / 1e8 : 0;
194
+ }
195
+
196
+ /**
197
+ * Turn feed rows into model entries. Keeps only chat-mode rows under a known
198
+ * provider prefix, requires usable limits, converts per-token rates to
199
+ * per-million (mirroring the CLI's perTokenToPerMTok).
200
+ */
201
+ export function parseDatasheet(
202
+ body: Record<string, DatasheetEntry>,
203
+ ): SupplementalModel[] {
204
+ const out: SupplementalModel[] = [];
205
+ for (const [key, e] of Object.entries(body)) {
206
+ if (!e || typeof e !== "object" || e.mode !== "chat") continue;
207
+ const prefix = PROVIDER_PREFIXES.find((p) => key.startsWith(p));
208
+ if (!prefix) continue;
209
+ const id = key.slice(prefix.length);
210
+ if (!id) continue;
211
+ if (
212
+ typeof e.context_length !== "number" ||
213
+ typeof e.max_output_tokens !== "number"
214
+ ) {
215
+ continue;
216
+ }
217
+ const slug = id.split("/").pop() as string;
218
+ const priced =
219
+ typeof e.input_cost_per_token === "number" ||
220
+ typeof e.output_cost_per_token === "number";
221
+ out.push({
222
+ id,
223
+ name:
224
+ typeof e.base_model === "string" && e.base_model ? e.base_model : slug,
225
+ context_window: e.context_length,
226
+ max_output_tokens: e.max_output_tokens,
227
+ limits_source: "datasheet",
228
+ ...(priced
229
+ ? {
230
+ cost_source: "datasheet",
231
+ cost: {
232
+ input: perMillion(e.input_cost_per_token),
233
+ output: perMillion(e.output_cost_per_token),
234
+ cache_read: perMillion(e.cache_read_input_token_cost),
235
+ cache_write: perMillion(e.cache_write_input_token_cost),
236
+ },
237
+ }
238
+ : {}),
239
+ ...(e.supports_reasoning === true ? { reasoning: true } : {}),
240
+ });
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** Merge supplemented ids into the CLI list. The CLI entry wins on collision. */
246
+ export function mergeModels<A extends { id: string }>(
247
+ cli: A[],
248
+ extra: A[],
249
+ ): A[] {
250
+ const seen = new Set(cli.map((m) => m.id));
251
+ return [...cli, ...extra.filter((m) => !seen.has(m.id))];
252
+ }
253
+
254
+ /**
255
+ * Entry point for the plugin: resolve the feed URL for this project, fetch
256
+ * (cached), and return supplementable models. Empty when the feature is off
257
+ * or the feed is unreachable and uncached — never throws, never blocks init
258
+ * beyond FETCH_TIMEOUT_MS.
259
+ *
260
+ * cacheDir may be any writable directory; the cache file is named
261
+ * datasheet-cache.json so it can never collide with a discovery config file.
262
+ */
263
+ export async function datasheetSupplement(
264
+ root: string,
265
+ cacheDir: string,
266
+ fetchImpl?: FetchLike,
267
+ ): Promise<SupplementalModel[]> {
268
+ const url = datasheetUrl(
269
+ process.env.GATEWAY_DATASHEET_URL,
270
+ process.env.HOME,
271
+ root,
272
+ );
273
+ if (!url) return [];
274
+ const body = await fetchDatasheet(
275
+ url,
276
+ join(cacheDir, "datasheet-cache.json"),
277
+ fetchImpl,
278
+ );
279
+ return body ? parseDatasheet(body) : [];
280
+ }
package/src/index.test.ts CHANGED
@@ -1,10 +1,32 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import {
3
6
  BifrostGateway,
7
+ type CliModel,
4
8
  isAllowedGatewayUrl,
9
+ isStale,
5
10
  toConfigModels,
6
11
  } from "./index.js";
7
12
 
13
+ /** HOME as the suite found it, restored after each test. */
14
+ const ORIGINAL_HOME = process.env.HOME;
15
+
16
+ beforeEach(() => {
17
+ // The config-hook tests run the real datasheetSupplement. Keep it
18
+ // deterministic and machine-independent: drop the env override and point
19
+ // HOME at an empty temp dir, so neither ~/.config/bifrost/datasheet.json
20
+ // nor the real ~/.cache/opencode cache is read or written.
21
+ delete process.env.GATEWAY_DATASHEET_URL;
22
+ process.env.HOME = mkdtempSync(join(tmpdir(), "gw-oc-home-"));
23
+ });
24
+
25
+ afterEach(() => {
26
+ if (ORIGINAL_HOME === undefined) delete process.env.HOME;
27
+ else process.env.HOME = ORIGINAL_HOME;
28
+ });
29
+
8
30
  const RESOLUTION = {
9
31
  env: "prod",
10
32
  root: "/repo",
@@ -16,26 +38,6 @@ const RESOLUTION = {
16
38
  headers: { "X-Org-Route": "prod" },
17
39
  };
18
40
 
19
- /** One model as `gateway-cli models` reports it. */
20
- type CliModel = {
21
- id: string;
22
- name: string;
23
- context_window: number;
24
- max_output_tokens: number;
25
- cost?: {
26
- input: number;
27
- output: number;
28
- cache_read: number;
29
- cache_write: number;
30
- };
31
- limits_source: string;
32
- cost_source?: string;
33
- /** Reasoning capability and applicable efforts, from the catalog. */
34
- reasoning?: boolean;
35
- reasoning_efforts?: string[];
36
- reasoning_budget_min?: number;
37
- };
38
-
39
41
  function cliPayload(models: CliModel[]): string {
40
42
  return JSON.stringify({
41
43
  wire: "openai-completions",
@@ -109,9 +111,33 @@ async function load(responses: Record<string, string>, calls: string[] = []) {
109
111
  return BifrostGateway({ directory: "/repo", $ } as never);
110
112
  }
111
113
 
114
+ /** The per-model shape the config hook writes into `provider.<id>.models`. */
115
+ type TestModel = {
116
+ name: string;
117
+ limit: { context: number; output: number };
118
+ /** Per-million-token rates; absent when nothing could price the model. */
119
+ cost?: {
120
+ input: number;
121
+ output: number;
122
+ cache_read: number;
123
+ cache_write: number;
124
+ };
125
+ };
126
+
127
+ /** One provider entry as the config hook writes it. */
128
+ type TestProvider = {
129
+ npm?: string;
130
+ name?: string;
131
+ options: { baseURL?: string; headers?: Record<string, string> };
132
+ models: Record<string, TestModel>;
133
+ };
134
+
135
+ /** The opencode config object the plugin mutates, as tests read it back. */
136
+ type TestConfig = { provider: Record<string, TestProvider> };
137
+
112
138
  async function applyConfig(responses: Record<string, string>, seed = {}) {
113
139
  const hooks = await load(responses);
114
- const cfg: Record<string, any> = { ...seed };
140
+ const cfg = { ...seed } as TestConfig;
115
141
  await hooks.config!(cfg as never);
116
142
  return cfg;
117
143
  }
@@ -231,6 +257,58 @@ describe("config hook", () => {
231
257
  });
232
258
  });
233
259
 
260
+ describe("datasheet supplement gate", () => {
261
+ const EXTRA_FLASH = {
262
+ id: "accounts/fireworks/models/glm-5p3-flash",
263
+ name: "glm-5p3-flash",
264
+ context_window: 1048576,
265
+ max_output_tokens: 131072,
266
+ limits_source: "datasheet",
267
+ };
268
+
269
+ test("appends supplement ids to the bifrost provider's models map", async () => {
270
+ // The module under test is statically imported at the top of this file,
271
+ // so register the ./datasheet.js mock and then re-import index.js fresh
272
+ // (cache-busted) — the plugin's datasheetSupplement/mergeModels bindings
273
+ // resolve to the mocks only in that new instance. mock.restore() in
274
+ // finally keeps every other test on the real module.
275
+ mock.module("./datasheet.js", () => ({
276
+ datasheetSupplement: async () => [EXTRA_FLASH],
277
+ // Same CLI-wins dedup as the real mergeModels, inlined to avoid a
278
+ // circular import of the module under mock.
279
+ mergeModels: (
280
+ cli: Array<{ id: string }>,
281
+ extra: Array<{ id: string }>,
282
+ ) => {
283
+ const seen = new Set(cli.map((m) => m.id));
284
+ return [...cli, ...extra.filter((m) => !seen.has(m.id))];
285
+ },
286
+ }));
287
+ try {
288
+ const mod = (await import(
289
+ `./index.js?${Math.random()}`
290
+ )) as typeof import("./index.js");
291
+ const hooks = await mod.BifrostGateway({
292
+ directory: "/repo",
293
+ $: fakeShell(OK_SHELL),
294
+ } as never);
295
+ const cfg = {} as TestConfig;
296
+ await hooks.config!(cfg as never);
297
+ const ids = Object.keys(cfg.provider.bifrost.models);
298
+ // The CLI's list survives; the supplemented id is appended alongside.
299
+ expect(ids).toContain("anthropic/claude-haiku-4-5-20251001");
300
+ expect(ids).toContain("bedrock/amazon.nova-lite-v1:0");
301
+ expect(ids).toContain(EXTRA_FLASH.id);
302
+ expect(cfg.provider.bifrost.models[EXTRA_FLASH.id].limit).toEqual({
303
+ context: 1048576,
304
+ output: 131072,
305
+ });
306
+ } finally {
307
+ mock.restore();
308
+ }
309
+ });
310
+ });
311
+
234
312
  describe("unpriced models", () => {
235
313
  test("omits cost entirely rather than declaring zero", () => {
236
314
  // A zeroed rate card renders as free, which is worse than blank.
@@ -255,6 +333,20 @@ describe("unpriced models", () => {
255
333
  });
256
334
  });
257
335
 
336
+ describe("disabled models", () => {
337
+ test("omits models the CLI marks disabled", () => {
338
+ const models = toConfigModels({
339
+ models: [{ ...NOVA, disabled: true }],
340
+ });
341
+ expect(models["bedrock/amazon.nova-lite-v1:0"]).toBeUndefined();
342
+ });
343
+
344
+ test("keeps models the CLI does not mark disabled", () => {
345
+ const models = toConfigModels({ models: [NOVA] });
346
+ expect(models["bedrock/amazon.nova-lite-v1:0"]).toBeDefined();
347
+ });
348
+ });
349
+
258
350
  describe("reasoning", () => {
259
351
  /** A reasoning model with a discrete effort set, like Fireworks kimi-k3. */
260
352
  const REASONING: CliModel = {
@@ -354,10 +446,21 @@ describe("auth.loader", () => {
354
446
  test("falls back to the stored credential without gateway-cli", async () => {
355
447
  const hooks = await load({});
356
448
  const opts = await hooks.auth!.loader!(
357
- async () => ({ type: "api", key: "stored-key" }) as never,
449
+ async () => ({ type: "api", key: "sk-bf-storedkey" }) as never,
358
450
  {} as never,
359
451
  );
360
- expect(opts.apiKey).toBe("stored-key");
452
+ expect(opts.apiKey).toBe("sk-bf-storedkey");
453
+ });
454
+
455
+ test("ignores a stored credential that is not a Bifrost virtual key", async () => {
456
+ // OpenCode saves whatever the connect prompt collects without
457
+ // validation; the loader must not hand a stray key to the gateway.
458
+ const hooks = await load({});
459
+ const opts = await hooks.auth!.loader!(
460
+ async () => ({ type: "api", key: "sk-not-bifrost" }) as never,
461
+ {} as never,
462
+ );
463
+ expect(opts).toEqual({});
361
464
  });
362
465
 
363
466
  test("returns no options when there is no credential at all", async () => {
@@ -370,16 +473,18 @@ describe("auth.loader", () => {
370
473
  });
371
474
 
372
475
  describe("auth.methods", () => {
373
- test("rejects a value that is not a Bifrost virtual key", async () => {
476
+ test("single prompt-less api method OpenCode asks for the key itself", async () => {
477
+ // OpenCode (1.18.x) collects method `prompts`, then unconditionally
478
+ // prompts for the key a second time; api-method `authorize` never runs
479
+ // from the TUI and the CLI double-prompts too. Defining either made
480
+ // /connect ask for the virtual key twice.
374
481
  const hooks = await load(OK_SHELL);
375
482
  const method = hooks.auth!.methods[0] as any;
376
- expect(await method.authorize({ key: "nope" })).toEqual({ type: "failed" });
377
- expect(await method.authorize({ key: "sk-bf-good" })).toEqual({
378
- type: "success",
379
- key: "sk-bf-good",
380
- });
381
- expect(method.prompts[0].validate("nope")).toBeString();
382
- expect(method.prompts[0].validate("sk-bf-good")).toBeUndefined();
483
+ expect(hooks.auth!.methods).toHaveLength(1);
484
+ expect(method.type).toBe("api");
485
+ expect(method.label).toContain("Bifrost Gateway");
486
+ expect(method.prompts).toBeUndefined();
487
+ expect(method.authorize).toBeUndefined();
383
488
  });
384
489
  });
385
490
 
@@ -394,3 +499,19 @@ describe("gateway URL allowlist", () => {
394
499
  expect(isAllowedGatewayUrl("not a url")).toBe(false);
395
500
  });
396
501
  });
502
+
503
+ describe("stale-plugin notice", () => {
504
+ test("warns only when the registry is ahead", () => {
505
+ expect(isStale("0.1.0", "0.2.1")).toBe(true);
506
+ expect(isStale("0.2.0", "0.2.1")).toBe(true);
507
+ expect(isStale("0.2.1", "0.2.1")).toBe(false);
508
+ // A dev build ahead of the registry is not stale.
509
+ expect(isStale("0.3.0", "0.2.1")).toBe(false);
510
+ expect(isStale("0.2.2", "0.2.1")).toBe(false);
511
+ });
512
+
513
+ test("treats unparseable differences as stale rather than guessing", () => {
514
+ expect(isStale("dev", "0.2.1")).toBe(true);
515
+ expect(isStale("dev", "dev")).toBe(false);
516
+ });
517
+ });
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
+ import { homedir } from "node:os";
3
+ import { datasheetSupplement, mergeModels } from "./datasheet.js";
2
4
 
3
5
  /** Provider id registered with OpenCode. */
4
6
  const PROVIDER_ID = "bifrost";
@@ -33,6 +35,15 @@ type Resolution = {
33
35
  reasoning_effort?: string;
34
36
  };
35
37
 
38
+ /** One entry in OpenCode's `provider` config map, as this plugin writes it. */
39
+ type ProviderEntry = {
40
+ options?: Record<string, unknown>;
41
+ models?: Record<string, ConfigModel>;
42
+ // OpenCode's provider config carries more fields than this plugin sets;
43
+ // user overrides are preserved verbatim via the spread in the config hook.
44
+ [key: string]: unknown;
45
+ };
46
+
36
47
  /** The model shape OpenCode reads from `provider.<id>.models`. */
37
48
  type ConfigModel = {
38
49
  name: string;
@@ -54,7 +65,7 @@ type ConfigModel = {
54
65
  };
55
66
 
56
67
  /** One model as reported by `gateway-cli models`. */
57
- type CliModel = {
68
+ export type CliModel = {
58
69
  id: string;
59
70
  name: string;
60
71
  context_window: number;
@@ -67,13 +78,16 @@ type CliModel = {
67
78
  };
68
79
  limits_source: string;
69
80
  cost_source?: string;
81
+ /** True when the gateway lists the model but it rejects completion on
82
+ * this key (not entitled, needs provisioned throughput, retired). */
83
+ disabled?: boolean;
70
84
  /** Reasoning capability and applicable efforts, from the catalog. */
71
85
  reasoning?: boolean;
72
86
  reasoning_efforts?: string[];
73
87
  reasoning_budget_min?: number;
74
88
  };
75
89
 
76
- type CliModels = { models?: CliModel[] };
90
+ export type CliModels = { models?: CliModel[] };
77
91
 
78
92
  /**
79
93
  * Bifrost gateway provider for OpenCode.
@@ -91,7 +105,136 @@ type CliModels = { models?: CliModel[] };
91
105
  * the picker. The v2 `provider.models` hook is not used because the v2
92
106
  * catalog is not active in this version.
93
107
  */
94
- export const BifrostGateway: Plugin = async ({ directory, $ }) => {
108
+ /**
109
+ * How long a "plugin is current" answer is trusted before the registry is
110
+ * asked again. Kept on disk so the once-daily check costs nothing on the
111
+ * frequent path (every opencode startup constructs this plugin).
112
+ */
113
+ const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
114
+
115
+ /** npm registry endpoint for the published package's latest version. */
116
+ const REGISTRY_URL =
117
+ "https://registry.npmjs.org/@stablekernel/opencode-bifrost/latest";
118
+
119
+ /** The version of this build, read from the package's own package.json. */
120
+ function ownVersion(): string | null {
121
+ try {
122
+ const pkg = require("../package.json") as { version?: string };
123
+ return pkg.version ?? null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ type VersionCheckCache = { checkedAt: number; latest: string };
130
+
131
+ /** opencode's plugin cache root; matches where the host itself caches. */
132
+ function versionCheckPath(): string | null {
133
+ const home = process.env.HOME;
134
+ if (!home) return null;
135
+ const base = process.env.XDG_CACHE_HOME ?? `${home}/.cache`;
136
+ return `${base}/opencode/bifrost-version-check.json`;
137
+ }
138
+
139
+ /** Where the datasheet feed cache lives; same base as the version check. */
140
+ function datasheetCacheDir(): string | null {
141
+ const home = process.env.HOME || homedir() || null;
142
+ const base = process.env.XDG_CACHE_HOME ?? (home ? `${home}/.cache` : null);
143
+ return base ? `${base}/opencode` : null;
144
+ }
145
+
146
+ /**
147
+ * The latest published version, from a fresh-enough cache file when present
148
+ * and the npm registry otherwise. Null means unknown — offline, timeout, or
149
+ * an unreadable cache — and never warns.
150
+ */
151
+ async function latestPublishedVersion(): Promise<string | null> {
152
+ const path = versionCheckPath();
153
+ if (path) {
154
+ try {
155
+ const cached = JSON.parse(
156
+ await Bun.file(path).text(),
157
+ ) as VersionCheckCache;
158
+ if (
159
+ typeof cached.latest === "string" &&
160
+ Date.now() - cached.checkedAt < VERSION_CHECK_TTL_MS
161
+ ) {
162
+ return cached.latest;
163
+ }
164
+ } catch {
165
+ // Missing or corrupt cache: fall through to the registry.
166
+ }
167
+ }
168
+ let latest: string | null = null;
169
+ try {
170
+ const res = await fetch(REGISTRY_URL, {
171
+ signal: AbortSignal.timeout(1500),
172
+ });
173
+ if (res.ok) {
174
+ const body = (await res.json()) as { version?: string };
175
+ latest = typeof body.version === "string" ? body.version : null;
176
+ }
177
+ } catch {
178
+ return null; // offline or slow registry: stay silent
179
+ }
180
+ if (latest && path) {
181
+ try {
182
+ const { mkdirSync, writeFileSync } = await import("node:fs");
183
+ const { dirname } = await import("node:path");
184
+ mkdirSync(dirname(path), { recursive: true });
185
+ writeFileSync(
186
+ path,
187
+ JSON.stringify({
188
+ checkedAt: Date.now(),
189
+ latest,
190
+ } satisfies VersionCheckCache),
191
+ );
192
+ } catch {
193
+ // A failed cache write just means the next start asks again.
194
+ }
195
+ }
196
+ return latest;
197
+ }
198
+
199
+ /**
200
+ * Whether the installed build should warn about a newer release. Dev builds
201
+ * ahead of the registry (installed > latest) are not stale.
202
+ */
203
+ export function isStale(installed: string, latest: string): boolean {
204
+ if (installed === latest) return false;
205
+ // Strict semver core only: parseInt would silently accept "0.3.0-dev" as
206
+ // 0.3.0 and misjudge the comparison. Odd shapes (prereleases, two-part
207
+ // versions) fall back to "warn on any difference" rather than a guess.
208
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
209
+ const a = SEMVER.exec(installed);
210
+ const b = SEMVER.exec(latest);
211
+ if (!a || !b) return true;
212
+ for (let i = 1; i <= 3; i++) {
213
+ const av = Number(a[i]);
214
+ const bv = Number(b[i]);
215
+ if (bv > av) return true;
216
+ if (bv < av) return false;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ /**
222
+ * Report a newer published plugin exactly once per version via the host's
223
+ * log. Fire-and-forget from the config hook: version drift must never block
224
+ * or fail provider registration.
225
+ */
226
+ async function announceIfStale(log: (message: string) => void): Promise<void> {
227
+ const installed = ownVersion();
228
+ if (!installed) return;
229
+ const latest = await latestPublishedVersion();
230
+ if (!latest || !isStale(installed, latest)) return;
231
+ log(
232
+ `opencode-bifrost ${latest} is available (installed ${installed}). ` +
233
+ "Run `gateway-cli update` to refresh.",
234
+ );
235
+ }
236
+
237
+ export const BifrostGateway: Plugin = async ({ client, directory, $ }) => {
95
238
  async function cli(args: string[]): Promise<string | null> {
96
239
  try {
97
240
  const out = await $`${CLI} ${args}`.cwd(directory).quiet().text();
@@ -120,11 +263,20 @@ export const BifrostGateway: Plugin = async ({ directory, $ }) => {
120
263
 
121
264
  // Model discovery goes through the CLI, so the key is never needed here —
122
265
  // only auth.loader below, which must hand it to the SDK, sees it.
266
+ // The datasheet supplement adds wildcard-provider ids the CLI cannot list
267
+ // yet (feed Bifrost itself syncs from); CLI-listed ids always win.
268
+ // `directory` is the plugin's closure variable, not a parameter.
123
269
  async function listModels(): Promise<CliModels | null> {
124
270
  const raw = await cli(["models", "--wire=openai"]);
125
271
  if (!raw) return null;
126
272
  try {
127
- return JSON.parse(raw) as CliModels;
273
+ const payload = JSON.parse(raw) as CliModels;
274
+ const cacheDir = datasheetCacheDir();
275
+ if (cacheDir) {
276
+ const extra = await datasheetSupplement(directory, cacheDir);
277
+ payload.models = mergeModels(payload.models ?? [], extra);
278
+ }
279
+ return payload;
128
280
  } catch {
129
281
  return null;
130
282
  }
@@ -136,11 +288,26 @@ export const BifrostGateway: Plugin = async ({ directory, $ }) => {
136
288
  * writes only to the in-memory config object.
137
289
  */
138
290
  config: async (cfg) => {
291
+ // Stale-plugin notice runs beside registration, never inside it.
292
+ void announceIfStale((message) => {
293
+ void client.app
294
+ .log({
295
+ body: {
296
+ service: "opencode-bifrost",
297
+ level: "warn",
298
+ message,
299
+ },
300
+ })
301
+ .catch(() => {});
302
+ }).catch(() => {});
303
+
139
304
  const info = await resolve();
140
305
  if (!info) return;
141
306
 
142
- const providers = ((cfg as Record<string, any>).provider ??= {});
143
- const existing = providers[PROVIDER_ID] ?? {};
307
+ const providers = ((
308
+ cfg as { provider?: Record<string, ProviderEntry> }
309
+ ).provider ??= {});
310
+ const existing: ProviderEntry = providers[PROVIDER_ID] ?? {};
144
311
  const models = toConfigModels(await listModels(), info.reasoning_effort);
145
312
 
146
313
  providers[PROVIDER_ID] = {
@@ -172,7 +339,14 @@ export const BifrostGateway: Plugin = async ({ directory, $ }) => {
172
339
  let apiKey = await readKey();
173
340
  if (!apiKey) {
174
341
  const stored = await getAuth().catch(() => undefined);
175
- if (stored && stored.type === "api") apiKey = stored.key;
342
+ // OpenCode stores whatever was typed at the connect prompt with no
343
+ // validation; a stray non-Bifrost key must not reach the gateway.
344
+ if (
345
+ stored &&
346
+ stored.type === "api" &&
347
+ stored.key.startsWith("sk-bf-")
348
+ )
349
+ apiKey = stored.key;
176
350
  }
177
351
  if (!apiKey) return {};
178
352
  return {
@@ -188,24 +362,14 @@ export const BifrostGateway: Plugin = async ({ directory, $ }) => {
188
362
  {
189
363
  type: "api",
190
364
  label: "Bifrost Gateway (paste a virtual key from the dashboard)",
191
- prompts: [
192
- {
193
- type: "text",
194
- key: "key",
195
- message: "Virtual key",
196
- placeholder: "sk-bf-…",
197
- validate: (value: string) =>
198
- value.startsWith("sk-bf-")
199
- ? undefined
200
- : "Expected a Bifrost virtual key starting with sk-bf-",
201
- },
202
- ],
203
- authorize: async (inputs?: Record<string, string>) => {
204
- const key = inputs?.key?.trim();
205
- if (!key || !key.startsWith("sk-bf-"))
206
- return { type: "failed" as const };
207
- return { type: "success" as const, key };
208
- },
365
+ // No `prompts` and no `authorize`: OpenCode treats api-method
366
+ // prompts as extra metadata questions and then still asks for the
367
+ // key itself (dialog-provider.tsx renders PromptsMethod, then an
368
+ // unconditional ApiMethod prompt; the CLI adds its own password
369
+ // prompt before calling authorize with the prompt inputs only) —
370
+ // defining prompts made /connect ask for the key twice. The key is
371
+ // whatever the single prompt collects; a non-virtual-key value is
372
+ // rejected by the loader fallback below, not at login time.
209
373
  },
210
374
  ],
211
375
  },
@@ -226,6 +390,7 @@ export function toConfigModels(
226
390
  const out: Record<string, ConfigModel> = {};
227
391
  for (const m of payload?.models ?? []) {
228
392
  if (!m?.id) continue;
393
+ if (m.disabled) continue; // unavailable on this key: never offer it
229
394
  const efforts = m.reasoning_efforts ?? [];
230
395
  const entry: ConfigModel = {
231
396
  name: m.cost ? m.name : `${m.name}${UNPRICED_SUFFIX}`,