@stablekernel/opencode-bifrost 0.2.2 → 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 +122 -35
- package/src/index.ts +37 -22
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.
|
|
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,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
|
|
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: "
|
|
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
|
|
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("
|
|
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(
|
|
395
|
-
expect(
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
},
|