@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 +29 -0
- package/package.json +1 -1
- package/src/datasheet.test.ts +300 -0
- package/src/datasheet.ts +280 -0
- package/src/index.test.ts +153 -32
- package/src/index.ts +190 -25
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
|
+
});
|
package/src/datasheet.ts
ADDED
|
@@ -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
|
|
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: "
|
|
449
|
+
async () => ({ type: "api", key: "sk-bf-storedkey" }) as never,
|
|
358
450
|
{} as never,
|
|
359
451
|
);
|
|
360
|
-
expect(opts.apiKey).toBe("
|
|
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("
|
|
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(
|
|
377
|
-
expect(
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = ((
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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}`,
|