@stablekernel/opencode-bifrost 0.1.0
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 +26 -0
- package/package.json +29 -0
- package/src/index.test.ts +364 -0
- package/src/index.ts +268 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @stablekernel/opencode-bifrost
|
|
2
|
+
|
|
3
|
+
OpenCode plugin that routes model traffic through a [Bifrost](https://docs.getbifrost.ai) LLM gateway, with per-project virtual-key resolution delegated to `gateway-cli`.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Registers a `bifrost` provider on OpenCode's OpenAI-compatible surface.
|
|
8
|
+
- Fetches the model list at startup through `gateway-cli models --wire=openai`, with per-model context/output limits and pricing already applied — no catalog is checked in, so a model added to the gateway appears without a local edit.
|
|
9
|
+
- Seeds reasoning capability per model: catalog-flagged models are registered `reasoning: true` with one variant per effort value they accept, and the config cascade's default `reasoning_effort` is seeded as the model's `options.reasoningEffort` when the model accepts it. The static `gateway-cli emit opencode` block mirrors this.
|
|
10
|
+
- Resolves the virtual key per project directory (`gateway-cli resolve` / `gateway-cli key print`), so two projects on different keys never collide in OpenCode's global auth store. Falls back to a credential stored via `opencode auth login` on machines without `gateway-cli`.
|
|
11
|
+
|
|
12
|
+
No secret is held by the plugin; the key is read at runtime.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- `gateway-cli` on `PATH` (or the `GATEWAY_CLI_BIN` env var pointing at it), connected once with `gateway-cli connect`.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
opencode plugin @stablekernel/opencode-bifrost
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
UNLICENSED — published for installation convenience; all rights reserved. Intended for use within the organization that operates the Bifrost gateway.
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stablekernel/opencode-bifrost",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin that routes models through the Bifrost gateway, resolving per-project virtual keys via gateway-cli.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@opencode-ai/plugin": ">=1.18.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@opencode-ai/plugin": "^1.18.18",
|
|
22
|
+
"@opencode-ai/sdk": "^1.18.18",
|
|
23
|
+
"@types/bun": "^1.3.14",
|
|
24
|
+
"@types/node": "^22.10.0",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
},
|
|
27
|
+
"license": "UNLICENSED",
|
|
28
|
+
"private": false
|
|
29
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
BifrostGateway,
|
|
4
|
+
isAllowedGatewayUrl,
|
|
5
|
+
toConfigModels,
|
|
6
|
+
} from "./index.js";
|
|
7
|
+
|
|
8
|
+
const RESOLUTION = {
|
|
9
|
+
env: "prod",
|
|
10
|
+
root: "/repo",
|
|
11
|
+
anthropic_base_url: "https://gw.example/anthropic",
|
|
12
|
+
openai_base_url: "https://gw.example/openai/v1",
|
|
13
|
+
has_key: true,
|
|
14
|
+
key_name: "acme",
|
|
15
|
+
key_source: "/keys/acme.env",
|
|
16
|
+
headers: { "X-Org-Route": "prod" },
|
|
17
|
+
};
|
|
18
|
+
|
|
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
|
+
function cliPayload(models: CliModel[]): string {
|
|
40
|
+
return JSON.stringify({
|
|
41
|
+
wire: "openai-completions",
|
|
42
|
+
env: "prod",
|
|
43
|
+
catalog: {
|
|
44
|
+
source: "https://models.dev/api.json",
|
|
45
|
+
fetched: "2026-01-01T00:00:00Z",
|
|
46
|
+
},
|
|
47
|
+
models,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const HAIKU: CliModel = {
|
|
52
|
+
id: "anthropic/claude-haiku-4-5-20251001",
|
|
53
|
+
name: "Claude Haiku 4.5",
|
|
54
|
+
context_window: 200000,
|
|
55
|
+
max_output_tokens: 64000,
|
|
56
|
+
cost: { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 },
|
|
57
|
+
limits_source: "gateway",
|
|
58
|
+
cost_source: "catalog",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const NOVA: CliModel = {
|
|
62
|
+
id: "bedrock/amazon.nova-lite-v1:0",
|
|
63
|
+
name: "Nova Lite",
|
|
64
|
+
context_window: 300000,
|
|
65
|
+
max_output_tokens: 8192,
|
|
66
|
+
cost: { input: 0.06, output: 0.24, cache_read: 0.015, cache_write: 0 },
|
|
67
|
+
limits_source: "catalog",
|
|
68
|
+
cost_source: "catalog",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A model nothing could price. */
|
|
72
|
+
const UNPRICED: CliModel = {
|
|
73
|
+
id: "bedrock/deepseek.v3.1",
|
|
74
|
+
name: "deepseek.v3.1 (bedrock)",
|
|
75
|
+
context_window: 200000,
|
|
76
|
+
max_output_tokens: 4096,
|
|
77
|
+
limits_source: "floor",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fake Bun shell. The plugin calls $`${CLI} ${args}`.cwd(d).quiet().text(),
|
|
82
|
+
* so the tag must return a chainable object.
|
|
83
|
+
*/
|
|
84
|
+
function fakeShell(responses: Record<string, string>, calls: string[] = []) {
|
|
85
|
+
return (_strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
86
|
+
const argv = (values[1] as string[]) ?? [];
|
|
87
|
+
const cmd = argv.join(" ");
|
|
88
|
+
const chain = {
|
|
89
|
+
cwd: () => chain,
|
|
90
|
+
quiet: () => chain,
|
|
91
|
+
text: async () => {
|
|
92
|
+
calls.push(cmd);
|
|
93
|
+
if (!(cmd in responses)) throw new Error(`command failed: ${cmd}`);
|
|
94
|
+
return responses[cmd];
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
return chain;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const OK_SHELL = {
|
|
102
|
+
"resolve --json": JSON.stringify(RESOLUTION),
|
|
103
|
+
"key print": "sk-bf-projectkey\n",
|
|
104
|
+
"models --wire=openai": cliPayload([HAIKU, NOVA]),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
async function load(responses: Record<string, string>, calls: string[] = []) {
|
|
108
|
+
const $ = fakeShell(responses, calls);
|
|
109
|
+
return BifrostGateway({ directory: "/repo", $ } as never);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function applyConfig(responses: Record<string, string>, seed = {}) {
|
|
113
|
+
const hooks = await load(responses);
|
|
114
|
+
const cfg: Record<string, any> = { ...seed };
|
|
115
|
+
await hooks.config!(cfg as never);
|
|
116
|
+
return cfg;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("config hook", () => {
|
|
120
|
+
test("registers the provider and its live models without touching the repo", async () => {
|
|
121
|
+
const cfg = await applyConfig(OK_SHELL);
|
|
122
|
+
const p = cfg.provider.bifrost;
|
|
123
|
+
expect(p.npm).toBe("@ai-sdk/openai-compatible");
|
|
124
|
+
expect(p.options.baseURL).toBe("https://gw.example/openai/v1");
|
|
125
|
+
expect(p.options.headers).toEqual({ "X-Org-Route": "prod" });
|
|
126
|
+
|
|
127
|
+
// Verified against OpenCode 1.18.18: this map is what reaches the picker.
|
|
128
|
+
expect(Object.keys(p.models).sort()).toEqual([
|
|
129
|
+
"anthropic/claude-haiku-4-5-20251001",
|
|
130
|
+
"bedrock/amazon.nova-lite-v1:0",
|
|
131
|
+
]);
|
|
132
|
+
expect(p.models["anthropic/claude-haiku-4-5-20251001"].name).toBe(
|
|
133
|
+
"Claude Haiku 4.5",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("publishes the real per-model limits from the CLI", async () => {
|
|
138
|
+
const cfg = await applyConfig(OK_SHELL);
|
|
139
|
+
const m = cfg.provider.bifrost.models;
|
|
140
|
+
expect(m["anthropic/claude-haiku-4-5-20251001"].limit).toEqual({
|
|
141
|
+
context: 200000,
|
|
142
|
+
output: 64000,
|
|
143
|
+
});
|
|
144
|
+
// Nova's real cap is 10000; the declared value must stay under it.
|
|
145
|
+
expect(m["bedrock/amazon.nova-lite-v1:0"].limit.output).toBe(8192);
|
|
146
|
+
expect(m["bedrock/amazon.nova-lite-v1:0"].limit.output).toBeLessThan(10000);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("publishes pricing so the cost readout is no longer zero", async () => {
|
|
150
|
+
const cfg = await applyConfig(OK_SHELL);
|
|
151
|
+
expect(
|
|
152
|
+
cfg.provider.bifrost.models["anthropic/claude-haiku-4-5-20251001"].cost,
|
|
153
|
+
).toEqual({ input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("does not read the key: discovery goes through the CLI", async () => {
|
|
157
|
+
// The CLI holds the credential and returns only non-secret metadata.
|
|
158
|
+
const calls: string[] = [];
|
|
159
|
+
const hooks = await load(OK_SHELL, calls);
|
|
160
|
+
await hooks.config!({} as never);
|
|
161
|
+
expect(calls).toContain("models --wire=openai");
|
|
162
|
+
expect(calls).not.toContain("key print");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("leaves config untouched when gateway-cli is missing", async () => {
|
|
166
|
+
const cfg = await applyConfig({});
|
|
167
|
+
expect(cfg.provider).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("registers with an empty picker when the CLI cannot list models", async () => {
|
|
171
|
+
const cfg = await applyConfig({
|
|
172
|
+
"resolve --json": JSON.stringify(RESOLUTION),
|
|
173
|
+
"key print": "sk-bf-projectkey\n",
|
|
174
|
+
// models command omitted: it fails
|
|
175
|
+
});
|
|
176
|
+
expect(cfg.provider.bifrost.models).toEqual({});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("registers with an empty picker when the CLI emits unparseable output", async () => {
|
|
180
|
+
const cfg = await applyConfig({
|
|
181
|
+
...OK_SHELL,
|
|
182
|
+
"models --wire=openai": "not json",
|
|
183
|
+
});
|
|
184
|
+
expect(cfg.provider.bifrost.models).toEqual({});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("does not clobber a user's existing bifrost overrides", async () => {
|
|
188
|
+
const cfg = await applyConfig(OK_SHELL, {
|
|
189
|
+
provider: {
|
|
190
|
+
bifrost: { name: "Mine", models: { "my/model": { name: "Mine" } } },
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
expect(cfg.provider.bifrost.name).toBe("Mine");
|
|
194
|
+
expect(cfg.provider.bifrost.models["my/model"].name).toBe("Mine");
|
|
195
|
+
// Gateway models are still merged in alongside the override.
|
|
196
|
+
expect(
|
|
197
|
+
cfg.provider.bifrost.models["anthropic/claude-haiku-4-5-20251001"],
|
|
198
|
+
).toBeDefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("refuses a non-TLS remote gateway", async () => {
|
|
202
|
+
const cfg = await applyConfig({
|
|
203
|
+
...OK_SHELL,
|
|
204
|
+
"resolve --json": JSON.stringify({
|
|
205
|
+
...RESOLUTION,
|
|
206
|
+
openai_base_url: "http://evil.example/v1",
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
expect(cfg.provider).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("unpriced models", () => {
|
|
214
|
+
test("omits cost entirely rather than declaring zero", () => {
|
|
215
|
+
// A zeroed rate card renders as free, which is worse than blank.
|
|
216
|
+
const models = toConfigModels({ models: [UNPRICED] });
|
|
217
|
+
expect(models["bedrock/deepseek.v3.1"].cost).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("says so in the name, since a blank cost is ambiguous", () => {
|
|
221
|
+
const models = toConfigModels({ models: [UNPRICED] });
|
|
222
|
+
expect(models["bedrock/deepseek.v3.1"].name).toContain("pricing unknown");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("leaves a priced model's name alone", () => {
|
|
226
|
+
const models = toConfigModels({ models: [NOVA] });
|
|
227
|
+
expect(models["bedrock/amazon.nova-lite-v1:0"].name).toBe("Nova Lite");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("tolerates an empty or absent model list", () => {
|
|
231
|
+
expect(toConfigModels(null)).toEqual({});
|
|
232
|
+
expect(toConfigModels({})).toEqual({});
|
|
233
|
+
expect(toConfigModels({ models: [] })).toEqual({});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("reasoning", () => {
|
|
238
|
+
/** A reasoning model with a discrete effort set, like Fireworks kimi-k3. */
|
|
239
|
+
const REASONING: CliModel = {
|
|
240
|
+
id: "fireworks/models/kimi-k3",
|
|
241
|
+
name: "Kimi K3",
|
|
242
|
+
context_window: 256000,
|
|
243
|
+
max_output_tokens: 32768,
|
|
244
|
+
cost: { input: 0.6, output: 2.5, cache_read: 0.1, cache_write: 0.6 },
|
|
245
|
+
limits_source: "gateway",
|
|
246
|
+
reasoning: true,
|
|
247
|
+
reasoning_efforts: ["low", "medium", "high", "max"],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** A toggle-only reasoning model: on/off, no effort levels. */
|
|
251
|
+
const TOGGLE_ONLY: CliModel = {
|
|
252
|
+
id: "fireworks/models/qwen3p8-max",
|
|
253
|
+
name: "Qwen3.8 Max",
|
|
254
|
+
context_window: 128000,
|
|
255
|
+
max_output_tokens: 8192,
|
|
256
|
+
limits_source: "catalog",
|
|
257
|
+
reasoning: true,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
test("surfaces reasoning: true and one variant per effort value", () => {
|
|
261
|
+
const models = toConfigModels({ models: [REASONING] });
|
|
262
|
+
const m = models["fireworks/models/kimi-k3"];
|
|
263
|
+
expect(m.reasoning).toBe(true);
|
|
264
|
+
expect(m.variants).toEqual({
|
|
265
|
+
low: { reasoningEffort: "low" },
|
|
266
|
+
medium: { reasoningEffort: "medium" },
|
|
267
|
+
high: { reasoningEffort: "high" },
|
|
268
|
+
max: { reasoningEffort: "max" },
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("seeds the default effort as model options when the model accepts it", () => {
|
|
273
|
+
const models = toConfigModels({ models: [REASONING] }, "high");
|
|
274
|
+
expect(models["fireworks/models/kimi-k3"].options).toEqual({
|
|
275
|
+
reasoningEffort: "high",
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("omits the default when the model does not accept that effort", () => {
|
|
280
|
+
// "medium" is not in deepseek-v4-flash's {high, max} set; seeding it
|
|
281
|
+
// would send a value the provider rejects. The capability + variants
|
|
282
|
+
// still ship so the user can pick an applicable one.
|
|
283
|
+
const FLASH: CliModel = {
|
|
284
|
+
...REASONING,
|
|
285
|
+
id: "fireworks/models/deepseek-v4-flash",
|
|
286
|
+
reasoning_efforts: ["high", "max"],
|
|
287
|
+
};
|
|
288
|
+
const models = toConfigModels({ models: [FLASH] }, "medium");
|
|
289
|
+
expect(
|
|
290
|
+
models["fireworks/models/deepseek-v4-flash"].options,
|
|
291
|
+
).toBeUndefined();
|
|
292
|
+
expect(models["fireworks/models/deepseek-v4-flash"].variants).toBeDefined();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("omits variants for a toggle-only reasoning model", () => {
|
|
296
|
+
const models = toConfigModels({ models: [TOGGLE_ONLY] });
|
|
297
|
+
const m = models["fireworks/models/qwen3p8-max"];
|
|
298
|
+
expect(m.reasoning).toBe(true);
|
|
299
|
+
expect(m.variants).toBeUndefined();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("leaves a non-reasoning model alone", () => {
|
|
303
|
+
const models = toConfigModels({ models: [NOVA] }, "high");
|
|
304
|
+
const m = models["bedrock/amazon.nova-lite-v1:0"];
|
|
305
|
+
expect(m.reasoning).toBeUndefined();
|
|
306
|
+
expect(m.variants).toBeUndefined();
|
|
307
|
+
expect(m.options).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("auth.loader", () => {
|
|
312
|
+
test("prefers the CLI-resolved key so the project cascade wins", async () => {
|
|
313
|
+
const hooks = await load(OK_SHELL);
|
|
314
|
+
const opts = await hooks.auth!.loader!(
|
|
315
|
+
async () => ({ type: "api", key: "stored" }) as never,
|
|
316
|
+
{} as never,
|
|
317
|
+
);
|
|
318
|
+
expect(opts.apiKey).toBe("sk-bf-projectkey");
|
|
319
|
+
expect(opts.baseURL).toBe("https://gw.example/openai/v1");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("falls back to the stored credential without gateway-cli", async () => {
|
|
323
|
+
const hooks = await load({});
|
|
324
|
+
const opts = await hooks.auth!.loader!(
|
|
325
|
+
async () => ({ type: "api", key: "stored-key" }) as never,
|
|
326
|
+
{} as never,
|
|
327
|
+
);
|
|
328
|
+
expect(opts.apiKey).toBe("stored-key");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("returns no options when there is no credential at all", async () => {
|
|
332
|
+
const hooks = await load({});
|
|
333
|
+
const opts = await hooks.auth!.loader!(async () => {
|
|
334
|
+
throw new Error("none");
|
|
335
|
+
}, {} as never);
|
|
336
|
+
expect(opts).toEqual({});
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("auth.methods", () => {
|
|
341
|
+
test("rejects a value that is not a Bifrost virtual key", async () => {
|
|
342
|
+
const hooks = await load(OK_SHELL);
|
|
343
|
+
const method = hooks.auth!.methods[0] as any;
|
|
344
|
+
expect(await method.authorize({ key: "nope" })).toEqual({ type: "failed" });
|
|
345
|
+
expect(await method.authorize({ key: "sk-bf-good" })).toEqual({
|
|
346
|
+
type: "success",
|
|
347
|
+
key: "sk-bf-good",
|
|
348
|
+
});
|
|
349
|
+
expect(method.prompts[0].validate("nope")).toBeString();
|
|
350
|
+
expect(method.prompts[0].validate("sk-bf-good")).toBeUndefined();
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("gateway URL allowlist", () => {
|
|
355
|
+
test("accepts TLS and loopback, rejects everything else", () => {
|
|
356
|
+
expect(isAllowedGatewayUrl("https://gateway.example.com/openai/v1")).toBe(
|
|
357
|
+
true,
|
|
358
|
+
);
|
|
359
|
+
expect(isAllowedGatewayUrl("http://localhost:8080/openai/v1")).toBe(true);
|
|
360
|
+
expect(isAllowedGatewayUrl("http://evil.example/v1")).toBe(false);
|
|
361
|
+
expect(isAllowedGatewayUrl("file:///etc/passwd")).toBe(false);
|
|
362
|
+
expect(isAllowedGatewayUrl("not a url")).toBe(false);
|
|
363
|
+
});
|
|
364
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
/** Provider id registered with OpenCode. */
|
|
4
|
+
const PROVIDER_ID = "bifrost";
|
|
5
|
+
|
|
6
|
+
/** Overridable so a non-PATH install still works. */
|
|
7
|
+
const CLI = process.env.GATEWAY_CLI_BIN ?? "gateway-cli";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Suffix for models nothing could price.
|
|
11
|
+
*
|
|
12
|
+
* The gateway reports no pricing, and the public catalog does not cover every
|
|
13
|
+
* model it serves. Declaring a zero rate would render as "free", so the rate
|
|
14
|
+
* card is omitted entirely and the name carries the caveat instead.
|
|
15
|
+
*/
|
|
16
|
+
const UNPRICED_SUFFIX = " (pricing unknown)";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Non-secret wiring emitted by `gateway-cli resolve --json`. The virtual key
|
|
20
|
+
* is deliberately absent: it is read separately via `gateway-cli key print`
|
|
21
|
+
* so this payload can be logged.
|
|
22
|
+
*/
|
|
23
|
+
type Resolution = {
|
|
24
|
+
env: string;
|
|
25
|
+
root: string;
|
|
26
|
+
anthropic_base_url: string;
|
|
27
|
+
openai_base_url: string;
|
|
28
|
+
has_key: boolean;
|
|
29
|
+
key_name?: string;
|
|
30
|
+
key_source?: string;
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
/** Resolved default reasoning effort (project over user), empty unset. */
|
|
33
|
+
reasoning_effort?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The model shape OpenCode reads from `provider.<id>.models`. */
|
|
37
|
+
type ConfigModel = {
|
|
38
|
+
name: string;
|
|
39
|
+
limit: { context: number; output: number };
|
|
40
|
+
/** Per-million-token rates. Absent when unknown — never zeroed. */
|
|
41
|
+
cost?: {
|
|
42
|
+
input: number;
|
|
43
|
+
output: number;
|
|
44
|
+
cache_read: number;
|
|
45
|
+
cache_write: number;
|
|
46
|
+
};
|
|
47
|
+
/** Whether the model supports extended thinking. */
|
|
48
|
+
reasoning?: boolean;
|
|
49
|
+
/** Named per-effort variants; each body is a provider-options overlay. */
|
|
50
|
+
variants?: Record<string, { reasoningEffort: string }>;
|
|
51
|
+
/** Provider options passed on every request (e.g. a seeded default
|
|
52
|
+
* reasoningEffort); a variant or agent override replaces these. */
|
|
53
|
+
options?: Record<string, unknown>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** One model as reported by `gateway-cli models`. */
|
|
57
|
+
type CliModel = {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
context_window: number;
|
|
61
|
+
max_output_tokens: number;
|
|
62
|
+
cost?: {
|
|
63
|
+
input: number;
|
|
64
|
+
output: number;
|
|
65
|
+
cache_read: number;
|
|
66
|
+
cache_write: number;
|
|
67
|
+
};
|
|
68
|
+
limits_source: string;
|
|
69
|
+
cost_source?: string;
|
|
70
|
+
/** Reasoning capability and applicable efforts, from the catalog. */
|
|
71
|
+
reasoning?: boolean;
|
|
72
|
+
reasoning_efforts?: string[];
|
|
73
|
+
reasoning_budget_min?: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type CliModels = { models?: CliModel[] };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Bifrost gateway provider for OpenCode.
|
|
80
|
+
*
|
|
81
|
+
* Resolution is delegated to gateway-cli, run with the plugin's `directory`
|
|
82
|
+
* as its working directory. That makes the key cascade
|
|
83
|
+
* ($BIFROST_VK > project > user default) apply per project, which OpenCode's
|
|
84
|
+
* own auth store cannot express: it holds one credential per provider id
|
|
85
|
+
* globally, so two projects on different virtual keys would collide.
|
|
86
|
+
*
|
|
87
|
+
* Models, limits and pricing all come from `gateway-cli models`, which reads
|
|
88
|
+
* the gateway live and fills in what the gateway does not report. Nothing is
|
|
89
|
+
* written into the project and no catalog goes stale. Verified against
|
|
90
|
+
* OpenCode 1.18.18: `provider.<id>.models` in config is the list that reaches
|
|
91
|
+
* the picker. The v2 `provider.models` hook is not used because the v2
|
|
92
|
+
* catalog is not active in this version.
|
|
93
|
+
*/
|
|
94
|
+
export const BifrostGateway: Plugin = async ({ directory, $ }) => {
|
|
95
|
+
async function cli(args: string[]): Promise<string | null> {
|
|
96
|
+
try {
|
|
97
|
+
const out = await $`${CLI} ${args}`.cwd(directory).quiet().text();
|
|
98
|
+
return out.trim();
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function resolve(): Promise<Resolution | null> {
|
|
105
|
+
const raw = await cli(["resolve", "--json"]);
|
|
106
|
+
if (!raw) return null;
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(raw) as Resolution;
|
|
109
|
+
// Allowlist the outbound origin before any derived URL is fetched.
|
|
110
|
+
return isAllowedGatewayUrl(parsed.openai_base_url) ? parsed : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readKey(): Promise<string | null> {
|
|
117
|
+
const key = await cli(["key", "print"]);
|
|
118
|
+
return key && key.startsWith("sk-bf-") ? key : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Model discovery goes through the CLI, so the key is never needed here —
|
|
122
|
+
// only auth.loader below, which must hand it to the SDK, sees it.
|
|
123
|
+
async function listModels(): Promise<CliModels | null> {
|
|
124
|
+
const raw = await cli(["models", "--wire=openai"]);
|
|
125
|
+
if (!raw) return null;
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(raw) as CliModels;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
/**
|
|
135
|
+
* Register the provider and its live model list. Runs at startup and
|
|
136
|
+
* writes only to the in-memory config object.
|
|
137
|
+
*/
|
|
138
|
+
config: async (cfg) => {
|
|
139
|
+
const info = await resolve();
|
|
140
|
+
if (!info) return;
|
|
141
|
+
|
|
142
|
+
const providers = ((cfg as Record<string, any>).provider ??= {});
|
|
143
|
+
const existing = providers[PROVIDER_ID] ?? {};
|
|
144
|
+
const models = toConfigModels(await listModels(), info.reasoning_effort);
|
|
145
|
+
|
|
146
|
+
providers[PROVIDER_ID] = {
|
|
147
|
+
npm: "@ai-sdk/openai-compatible",
|
|
148
|
+
name: `Bifrost Gateway (${info.env})`,
|
|
149
|
+
// A user's own overrides win over everything generated here.
|
|
150
|
+
...existing,
|
|
151
|
+
options: {
|
|
152
|
+
baseURL: info.openai_base_url,
|
|
153
|
+
...(info.headers ? { headers: info.headers } : {}),
|
|
154
|
+
...(existing.options ?? {}),
|
|
155
|
+
},
|
|
156
|
+
models: { ...models, ...(existing.models ?? {}) },
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
auth: {
|
|
161
|
+
provider: PROVIDER_ID,
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Supply provider options at startup. Prefers the CLI-resolved key so
|
|
165
|
+
* the per-project cascade wins; falls back to a credential stored by
|
|
166
|
+
* `opencode auth login` for machines without gateway-cli.
|
|
167
|
+
*/
|
|
168
|
+
loader: async (getAuth) => {
|
|
169
|
+
const info = await resolve();
|
|
170
|
+
let apiKey = await readKey();
|
|
171
|
+
if (!apiKey) {
|
|
172
|
+
const stored = await getAuth().catch(() => undefined);
|
|
173
|
+
if (stored && stored.type === "api") apiKey = stored.key;
|
|
174
|
+
}
|
|
175
|
+
if (!apiKey) return {};
|
|
176
|
+
return {
|
|
177
|
+
apiKey,
|
|
178
|
+
...(info ? { baseURL: info.openai_base_url } : {}),
|
|
179
|
+
...(info?.headers ? { headers: info.headers } : {}),
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
methods: [
|
|
184
|
+
{
|
|
185
|
+
type: "api",
|
|
186
|
+
label: "Bifrost Gateway (paste a virtual key from the dashboard)",
|
|
187
|
+
prompts: [
|
|
188
|
+
{
|
|
189
|
+
type: "text",
|
|
190
|
+
key: "key",
|
|
191
|
+
message: "Virtual key",
|
|
192
|
+
placeholder: "sk-bf-…",
|
|
193
|
+
validate: (value: string) =>
|
|
194
|
+
value.startsWith("sk-bf-")
|
|
195
|
+
? undefined
|
|
196
|
+
: "Expected a Bifrost virtual key starting with sk-bf-",
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
authorize: async (inputs?: Record<string, string>) => {
|
|
200
|
+
const key = inputs?.key?.trim();
|
|
201
|
+
if (!key || !key.startsWith("sk-bf-"))
|
|
202
|
+
return { type: "failed" as const };
|
|
203
|
+
return { type: "success" as const, key };
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Map the CLI's model list into OpenCode's provider config shape.
|
|
213
|
+
*
|
|
214
|
+
* Pricing is passed through only when known. OpenCode renders per-model cost,
|
|
215
|
+
* so a zeroed rate card would advertise a paid model as free; omitting it
|
|
216
|
+
* leaves the figure blank and the name carries the caveat.
|
|
217
|
+
*/
|
|
218
|
+
export function toConfigModels(
|
|
219
|
+
payload: CliModels | null,
|
|
220
|
+
defaultEffort?: string,
|
|
221
|
+
): Record<string, ConfigModel> {
|
|
222
|
+
const out: Record<string, ConfigModel> = {};
|
|
223
|
+
for (const m of payload?.models ?? []) {
|
|
224
|
+
if (!m?.id) continue;
|
|
225
|
+
const efforts = m.reasoning_efforts ?? [];
|
|
226
|
+
const entry: ConfigModel = {
|
|
227
|
+
name: m.cost ? m.name : `${m.name}${UNPRICED_SUFFIX}`,
|
|
228
|
+
limit: { context: m.context_window, output: m.max_output_tokens },
|
|
229
|
+
...(m.cost ? { cost: m.cost } : {}),
|
|
230
|
+
};
|
|
231
|
+
if (m.reasoning) {
|
|
232
|
+
entry.reasoning = true;
|
|
233
|
+
const variants: Record<string, { reasoningEffort: string }> = {};
|
|
234
|
+
for (const e of efforts) variants[e] = { reasoningEffort: e };
|
|
235
|
+
if (Object.keys(variants).length > 0) entry.variants = variants;
|
|
236
|
+
if (defaultEffort && efforts.includes(defaultEffort)) {
|
|
237
|
+
// Seeding at model level makes the effort the default unless a
|
|
238
|
+
// variant or agent override replaces it.
|
|
239
|
+
(entry as ConfigModel & { options?: Record<string, unknown> }).options =
|
|
240
|
+
{ reasoningEffort: defaultEffort };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
out[m.id] = entry;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Accept only TLS gateway origins, plus plain-HTTP loopback for local
|
|
250
|
+
* gateway development. Keeps a malformed or hostile config from redirecting
|
|
251
|
+
* model discovery and the credential that goes with it.
|
|
252
|
+
*/
|
|
253
|
+
export function isAllowedGatewayUrl(raw: string): boolean {
|
|
254
|
+
let url: URL;
|
|
255
|
+
try {
|
|
256
|
+
url = new URL(raw);
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
if (url.protocol === "https:") return true;
|
|
261
|
+
const loopback =
|
|
262
|
+
url.hostname === "localhost" ||
|
|
263
|
+
url.hostname === "127.0.0.1" ||
|
|
264
|
+
url.hostname === "::1";
|
|
265
|
+
return url.protocol === "http:" && loopback;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export default BifrostGateway;
|