@stablekernel/opencode-bifrost 0.2.2 → 0.2.3

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.2",
3
+ "version": "0.2.3",
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,11 +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,
5
9
  isStale,
6
10
  toConfigModels,
7
11
  } from "./index.js";
8
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
+
9
30
  const RESOLUTION = {
10
31
  env: "prod",
11
32
  root: "/repo",
@@ -17,29 +38,6 @@ const RESOLUTION = {
17
38
  headers: { "X-Org-Route": "prod" },
18
39
  };
19
40
 
20
- /** One model as `gateway-cli models` reports it. */
21
- type CliModel = {
22
- id: string;
23
- name: string;
24
- context_window: number;
25
- max_output_tokens: number;
26
- cost?: {
27
- input: number;
28
- output: number;
29
- cache_read: number;
30
- cache_write: number;
31
- };
32
- limits_source: string;
33
- cost_source?: string;
34
- /** True when the gateway lists the model but it rejects completion on
35
- * this key (not entitled, needs provisioned throughput, retired). */
36
- disabled?: boolean;
37
- /** Reasoning capability and applicable efforts, from the catalog. */
38
- reasoning?: boolean;
39
- reasoning_efforts?: string[];
40
- reasoning_budget_min?: number;
41
- };
42
-
43
41
  function cliPayload(models: CliModel[]): string {
44
42
  return JSON.stringify({
45
43
  wire: "openai-completions",
@@ -113,9 +111,33 @@ async function load(responses: Record<string, string>, calls: string[] = []) {
113
111
  return BifrostGateway({ directory: "/repo", $ } as never);
114
112
  }
115
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
+
116
138
  async function applyConfig(responses: Record<string, string>, seed = {}) {
117
139
  const hooks = await load(responses);
118
- const cfg: Record<string, any> = { ...seed };
140
+ const cfg = { ...seed } as TestConfig;
119
141
  await hooks.config!(cfg as never);
120
142
  return cfg;
121
143
  }
@@ -235,6 +257,58 @@ describe("config hook", () => {
235
257
  });
236
258
  });
237
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
+
238
312
  describe("unpriced models", () => {
239
313
  test("omits cost entirely rather than declaring zero", () => {
240
314
  // A zeroed rate card renders as free, which is worse than blank.
@@ -372,10 +446,21 @@ describe("auth.loader", () => {
372
446
  test("falls back to the stored credential without gateway-cli", async () => {
373
447
  const hooks = await load({});
374
448
  const opts = await hooks.auth!.loader!(
375
- async () => ({ type: "api", key: "stored-key" }) as never,
449
+ async () => ({ type: "api", key: "sk-bf-storedkey" }) as never,
450
+ {} as never,
451
+ );
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,
376
461
  {} as never,
377
462
  );
378
- expect(opts.apiKey).toBe("stored-key");
463
+ expect(opts).toEqual({});
379
464
  });
380
465
 
381
466
  test("returns no options when there is no credential at all", async () => {
@@ -388,16 +473,18 @@ describe("auth.loader", () => {
388
473
  });
389
474
 
390
475
  describe("auth.methods", () => {
391
- 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.
392
481
  const hooks = await load(OK_SHELL);
393
482
  const method = hooks.auth!.methods[0] as any;
394
- expect(await method.authorize({ key: "nope" })).toEqual({ type: "failed" });
395
- expect(await method.authorize({ key: "sk-bf-good" })).toEqual({
396
- type: "success",
397
- key: "sk-bf-good",
398
- });
399
- expect(method.prompts[0].validate("nope")).toBeString();
400
- 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();
401
488
  });
402
489
  });
403
490
 
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";
@@ -63,7 +65,7 @@ type ConfigModel = {
63
65
  };
64
66
 
65
67
  /** One model as reported by `gateway-cli models`. */
66
- type CliModel = {
68
+ export type CliModel = {
67
69
  id: string;
68
70
  name: string;
69
71
  context_window: number;
@@ -85,7 +87,7 @@ type CliModel = {
85
87
  reasoning_budget_min?: number;
86
88
  };
87
89
 
88
- type CliModels = { models?: CliModel[] };
90
+ export type CliModels = { models?: CliModel[] };
89
91
 
90
92
  /**
91
93
  * Bifrost gateway provider for OpenCode.
@@ -134,6 +136,13 @@ function versionCheckPath(): string | null {
134
136
  return `${base}/opencode/bifrost-version-check.json`;
135
137
  }
136
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
+
137
146
  /**
138
147
  * The latest published version, from a fresh-enough cache file when present
139
148
  * and the npm registry otherwise. Null means unknown — offline, timeout, or
@@ -254,11 +263,20 @@ export const BifrostGateway: Plugin = async ({ client, directory, $ }) => {
254
263
 
255
264
  // Model discovery goes through the CLI, so the key is never needed here —
256
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.
257
269
  async function listModels(): Promise<CliModels | null> {
258
270
  const raw = await cli(["models", "--wire=openai"]);
259
271
  if (!raw) return null;
260
272
  try {
261
- 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;
262
280
  } catch {
263
281
  return null;
264
282
  }
@@ -321,7 +339,14 @@ export const BifrostGateway: Plugin = async ({ client, directory, $ }) => {
321
339
  let apiKey = await readKey();
322
340
  if (!apiKey) {
323
341
  const stored = await getAuth().catch(() => undefined);
324
- 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;
325
350
  }
326
351
  if (!apiKey) return {};
327
352
  return {
@@ -337,24 +362,14 @@ export const BifrostGateway: Plugin = async ({ client, directory, $ }) => {
337
362
  {
338
363
  type: "api",
339
364
  label: "Bifrost Gateway (paste a virtual key from the dashboard)",
340
- prompts: [
341
- {
342
- type: "text",
343
- key: "key",
344
- message: "Virtual key",
345
- placeholder: "sk-bf-…",
346
- validate: (value: string) =>
347
- value.startsWith("sk-bf-")
348
- ? undefined
349
- : "Expected a Bifrost virtual key starting with sk-bf-",
350
- },
351
- ],
352
- authorize: async (inputs?: Record<string, string>) => {
353
- const key = inputs?.key?.trim();
354
- if (!key || !key.startsWith("sk-bf-"))
355
- return { type: "failed" as const };
356
- return { type: "success" as const, key };
357
- },
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.
358
373
  },
359
374
  ],
360
375
  },