@stablekernel/opencode-bifrost 0.2.0 → 0.2.2
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/package.json +1 -1
- package/src/index.test.ts +66 -0
- package/src/index.ts +159 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stablekernel/opencode-bifrost",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
package/src/index.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
BifrostGateway,
|
|
4
4
|
isAllowedGatewayUrl,
|
|
5
|
+
isStale,
|
|
5
6
|
toConfigModels,
|
|
6
7
|
} from "./index.js";
|
|
7
8
|
|
|
@@ -30,6 +31,9 @@ type CliModel = {
|
|
|
30
31
|
};
|
|
31
32
|
limits_source: string;
|
|
32
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;
|
|
33
37
|
/** Reasoning capability and applicable efforts, from the catalog. */
|
|
34
38
|
reasoning?: boolean;
|
|
35
39
|
reasoning_efforts?: string[];
|
|
@@ -153,6 +157,27 @@ describe("config hook", () => {
|
|
|
153
157
|
).toEqual({ input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 });
|
|
154
158
|
});
|
|
155
159
|
|
|
160
|
+
test("omits headers when resolve reports none", async () => {
|
|
161
|
+
const noHeaders: Record<string, unknown> = { ...RESOLUTION };
|
|
162
|
+
delete noHeaders.headers;
|
|
163
|
+
const cfg = await applyConfig({
|
|
164
|
+
...OK_SHELL,
|
|
165
|
+
"resolve --json": JSON.stringify(noHeaders),
|
|
166
|
+
});
|
|
167
|
+
expect(cfg.provider.bifrost.options.headers).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("treats an empty header map the same as no headers", async () => {
|
|
171
|
+
// Go-side mergeHeaders returns nil for an empty merge and the JSON tag
|
|
172
|
+
// omits it, so `{}` cannot reach the plugin in practice. This pins the
|
|
173
|
+
// behavior anyway so a future CLI change cannot quietly flip it.
|
|
174
|
+
const cfg = await applyConfig({
|
|
175
|
+
...OK_SHELL,
|
|
176
|
+
"resolve --json": JSON.stringify({ ...RESOLUTION, headers: {} }),
|
|
177
|
+
});
|
|
178
|
+
expect(cfg.provider.bifrost.options.headers).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
156
181
|
test("does not read the key: discovery goes through the CLI", async () => {
|
|
157
182
|
// The CLI holds the credential and returns only non-secret metadata.
|
|
158
183
|
const calls: string[] = [];
|
|
@@ -234,6 +259,20 @@ describe("unpriced models", () => {
|
|
|
234
259
|
});
|
|
235
260
|
});
|
|
236
261
|
|
|
262
|
+
describe("disabled models", () => {
|
|
263
|
+
test("omits models the CLI marks disabled", () => {
|
|
264
|
+
const models = toConfigModels({
|
|
265
|
+
models: [{ ...NOVA, disabled: true }],
|
|
266
|
+
});
|
|
267
|
+
expect(models["bedrock/amazon.nova-lite-v1:0"]).toBeUndefined();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("keeps models the CLI does not mark disabled", () => {
|
|
271
|
+
const models = toConfigModels({ models: [NOVA] });
|
|
272
|
+
expect(models["bedrock/amazon.nova-lite-v1:0"]).toBeDefined();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
237
276
|
describe("reasoning", () => {
|
|
238
277
|
/** A reasoning model with a discrete effort set, like Fireworks kimi-k3. */
|
|
239
278
|
const REASONING: CliModel = {
|
|
@@ -319,6 +358,17 @@ describe("auth.loader", () => {
|
|
|
319
358
|
expect(opts.baseURL).toBe("https://gw.example/openai/v1");
|
|
320
359
|
});
|
|
321
360
|
|
|
361
|
+
test("forwards the resolved headers alongside the key", async () => {
|
|
362
|
+
// The config hook sets headers at startup, but auth.loader also returns
|
|
363
|
+
// provider options at request time; both must carry the gateway headers.
|
|
364
|
+
const hooks = await load(OK_SHELL);
|
|
365
|
+
const opts = await hooks.auth!.loader!(
|
|
366
|
+
async () => ({ type: "api", key: "stored" }) as never,
|
|
367
|
+
{} as never,
|
|
368
|
+
);
|
|
369
|
+
expect(opts.headers).toEqual({ "X-Org-Route": "prod" });
|
|
370
|
+
});
|
|
371
|
+
|
|
322
372
|
test("falls back to the stored credential without gateway-cli", async () => {
|
|
323
373
|
const hooks = await load({});
|
|
324
374
|
const opts = await hooks.auth!.loader!(
|
|
@@ -362,3 +412,19 @@ describe("gateway URL allowlist", () => {
|
|
|
362
412
|
expect(isAllowedGatewayUrl("not a url")).toBe(false);
|
|
363
413
|
});
|
|
364
414
|
});
|
|
415
|
+
|
|
416
|
+
describe("stale-plugin notice", () => {
|
|
417
|
+
test("warns only when the registry is ahead", () => {
|
|
418
|
+
expect(isStale("0.1.0", "0.2.1")).toBe(true);
|
|
419
|
+
expect(isStale("0.2.0", "0.2.1")).toBe(true);
|
|
420
|
+
expect(isStale("0.2.1", "0.2.1")).toBe(false);
|
|
421
|
+
// A dev build ahead of the registry is not stale.
|
|
422
|
+
expect(isStale("0.3.0", "0.2.1")).toBe(false);
|
|
423
|
+
expect(isStale("0.2.2", "0.2.1")).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("treats unparseable differences as stale rather than guessing", () => {
|
|
427
|
+
expect(isStale("dev", "0.2.1")).toBe(true);
|
|
428
|
+
expect(isStale("dev", "dev")).toBe(false);
|
|
429
|
+
});
|
|
430
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,15 @@ type Resolution = {
|
|
|
33
33
|
reasoning_effort?: string;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
/** One entry in OpenCode's `provider` config map, as this plugin writes it. */
|
|
37
|
+
type ProviderEntry = {
|
|
38
|
+
options?: Record<string, unknown>;
|
|
39
|
+
models?: Record<string, ConfigModel>;
|
|
40
|
+
// OpenCode's provider config carries more fields than this plugin sets;
|
|
41
|
+
// user overrides are preserved verbatim via the spread in the config hook.
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
|
|
36
45
|
/** The model shape OpenCode reads from `provider.<id>.models`. */
|
|
37
46
|
type ConfigModel = {
|
|
38
47
|
name: string;
|
|
@@ -67,6 +76,9 @@ type CliModel = {
|
|
|
67
76
|
};
|
|
68
77
|
limits_source: string;
|
|
69
78
|
cost_source?: string;
|
|
79
|
+
/** True when the gateway lists the model but it rejects completion on
|
|
80
|
+
* this key (not entitled, needs provisioned throughput, retired). */
|
|
81
|
+
disabled?: boolean;
|
|
70
82
|
/** Reasoning capability and applicable efforts, from the catalog. */
|
|
71
83
|
reasoning?: boolean;
|
|
72
84
|
reasoning_efforts?: string[];
|
|
@@ -91,7 +103,129 @@ type CliModels = { models?: CliModel[] };
|
|
|
91
103
|
* the picker. The v2 `provider.models` hook is not used because the v2
|
|
92
104
|
* catalog is not active in this version.
|
|
93
105
|
*/
|
|
94
|
-
|
|
106
|
+
/**
|
|
107
|
+
* How long a "plugin is current" answer is trusted before the registry is
|
|
108
|
+
* asked again. Kept on disk so the once-daily check costs nothing on the
|
|
109
|
+
* frequent path (every opencode startup constructs this plugin).
|
|
110
|
+
*/
|
|
111
|
+
const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
112
|
+
|
|
113
|
+
/** npm registry endpoint for the published package's latest version. */
|
|
114
|
+
const REGISTRY_URL =
|
|
115
|
+
"https://registry.npmjs.org/@stablekernel/opencode-bifrost/latest";
|
|
116
|
+
|
|
117
|
+
/** The version of this build, read from the package's own package.json. */
|
|
118
|
+
function ownVersion(): string | null {
|
|
119
|
+
try {
|
|
120
|
+
const pkg = require("../package.json") as { version?: string };
|
|
121
|
+
return pkg.version ?? null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type VersionCheckCache = { checkedAt: number; latest: string };
|
|
128
|
+
|
|
129
|
+
/** opencode's plugin cache root; matches where the host itself caches. */
|
|
130
|
+
function versionCheckPath(): string | null {
|
|
131
|
+
const home = process.env.HOME;
|
|
132
|
+
if (!home) return null;
|
|
133
|
+
const base = process.env.XDG_CACHE_HOME ?? `${home}/.cache`;
|
|
134
|
+
return `${base}/opencode/bifrost-version-check.json`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The latest published version, from a fresh-enough cache file when present
|
|
139
|
+
* and the npm registry otherwise. Null means unknown — offline, timeout, or
|
|
140
|
+
* an unreadable cache — and never warns.
|
|
141
|
+
*/
|
|
142
|
+
async function latestPublishedVersion(): Promise<string | null> {
|
|
143
|
+
const path = versionCheckPath();
|
|
144
|
+
if (path) {
|
|
145
|
+
try {
|
|
146
|
+
const cached = JSON.parse(
|
|
147
|
+
await Bun.file(path).text(),
|
|
148
|
+
) as VersionCheckCache;
|
|
149
|
+
if (
|
|
150
|
+
typeof cached.latest === "string" &&
|
|
151
|
+
Date.now() - cached.checkedAt < VERSION_CHECK_TTL_MS
|
|
152
|
+
) {
|
|
153
|
+
return cached.latest;
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Missing or corrupt cache: fall through to the registry.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let latest: string | null = null;
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(REGISTRY_URL, {
|
|
162
|
+
signal: AbortSignal.timeout(1500),
|
|
163
|
+
});
|
|
164
|
+
if (res.ok) {
|
|
165
|
+
const body = (await res.json()) as { version?: string };
|
|
166
|
+
latest = typeof body.version === "string" ? body.version : null;
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
return null; // offline or slow registry: stay silent
|
|
170
|
+
}
|
|
171
|
+
if (latest && path) {
|
|
172
|
+
try {
|
|
173
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
174
|
+
const { dirname } = await import("node:path");
|
|
175
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
176
|
+
writeFileSync(
|
|
177
|
+
path,
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
checkedAt: Date.now(),
|
|
180
|
+
latest,
|
|
181
|
+
} satisfies VersionCheckCache),
|
|
182
|
+
);
|
|
183
|
+
} catch {
|
|
184
|
+
// A failed cache write just means the next start asks again.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return latest;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether the installed build should warn about a newer release. Dev builds
|
|
192
|
+
* ahead of the registry (installed > latest) are not stale.
|
|
193
|
+
*/
|
|
194
|
+
export function isStale(installed: string, latest: string): boolean {
|
|
195
|
+
if (installed === latest) return false;
|
|
196
|
+
// Strict semver core only: parseInt would silently accept "0.3.0-dev" as
|
|
197
|
+
// 0.3.0 and misjudge the comparison. Odd shapes (prereleases, two-part
|
|
198
|
+
// versions) fall back to "warn on any difference" rather than a guess.
|
|
199
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
200
|
+
const a = SEMVER.exec(installed);
|
|
201
|
+
const b = SEMVER.exec(latest);
|
|
202
|
+
if (!a || !b) return true;
|
|
203
|
+
for (let i = 1; i <= 3; i++) {
|
|
204
|
+
const av = Number(a[i]);
|
|
205
|
+
const bv = Number(b[i]);
|
|
206
|
+
if (bv > av) return true;
|
|
207
|
+
if (bv < av) return false;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Report a newer published plugin exactly once per version via the host's
|
|
214
|
+
* log. Fire-and-forget from the config hook: version drift must never block
|
|
215
|
+
* or fail provider registration.
|
|
216
|
+
*/
|
|
217
|
+
async function announceIfStale(log: (message: string) => void): Promise<void> {
|
|
218
|
+
const installed = ownVersion();
|
|
219
|
+
if (!installed) return;
|
|
220
|
+
const latest = await latestPublishedVersion();
|
|
221
|
+
if (!latest || !isStale(installed, latest)) return;
|
|
222
|
+
log(
|
|
223
|
+
`opencode-bifrost ${latest} is available (installed ${installed}). ` +
|
|
224
|
+
"Run `gateway-cli update` to refresh.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const BifrostGateway: Plugin = async ({ client, directory, $ }) => {
|
|
95
229
|
async function cli(args: string[]): Promise<string | null> {
|
|
96
230
|
try {
|
|
97
231
|
const out = await $`${CLI} ${args}`.cwd(directory).quiet().text();
|
|
@@ -136,11 +270,26 @@ export const BifrostGateway: Plugin = async ({ directory, $ }) => {
|
|
|
136
270
|
* writes only to the in-memory config object.
|
|
137
271
|
*/
|
|
138
272
|
config: async (cfg) => {
|
|
273
|
+
// Stale-plugin notice runs beside registration, never inside it.
|
|
274
|
+
void announceIfStale((message) => {
|
|
275
|
+
void client.app
|
|
276
|
+
.log({
|
|
277
|
+
body: {
|
|
278
|
+
service: "opencode-bifrost",
|
|
279
|
+
level: "warn",
|
|
280
|
+
message,
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
.catch(() => {});
|
|
284
|
+
}).catch(() => {});
|
|
285
|
+
|
|
139
286
|
const info = await resolve();
|
|
140
287
|
if (!info) return;
|
|
141
288
|
|
|
142
|
-
const providers = ((
|
|
143
|
-
|
|
289
|
+
const providers = ((
|
|
290
|
+
cfg as { provider?: Record<string, ProviderEntry> }
|
|
291
|
+
).provider ??= {});
|
|
292
|
+
const existing: ProviderEntry = providers[PROVIDER_ID] ?? {};
|
|
144
293
|
const models = toConfigModels(await listModels(), info.reasoning_effort);
|
|
145
294
|
|
|
146
295
|
providers[PROVIDER_ID] = {
|
|
@@ -150,7 +299,9 @@ export const BifrostGateway: Plugin = async ({ directory, $ }) => {
|
|
|
150
299
|
...existing,
|
|
151
300
|
options: {
|
|
152
301
|
baseURL: info.openai_base_url,
|
|
153
|
-
...(info.headers
|
|
302
|
+
...(info.headers && Object.keys(info.headers).length > 0
|
|
303
|
+
? { headers: info.headers }
|
|
304
|
+
: {}),
|
|
154
305
|
...(existing.options ?? {}),
|
|
155
306
|
},
|
|
156
307
|
models: { ...models, ...(existing.models ?? {}) },
|
|
@@ -176,7 +327,9 @@ export const BifrostGateway: Plugin = async ({ directory, $ }) => {
|
|
|
176
327
|
return {
|
|
177
328
|
apiKey,
|
|
178
329
|
...(info ? { baseURL: info.openai_base_url } : {}),
|
|
179
|
-
...(info?.headers
|
|
330
|
+
...(info?.headers && Object.keys(info.headers).length > 0
|
|
331
|
+
? { headers: info.headers }
|
|
332
|
+
: {}),
|
|
180
333
|
};
|
|
181
334
|
},
|
|
182
335
|
|
|
@@ -222,6 +375,7 @@ export function toConfigModels(
|
|
|
222
375
|
const out: Record<string, ConfigModel> = {};
|
|
223
376
|
for (const m of payload?.models ?? []) {
|
|
224
377
|
if (!m?.id) continue;
|
|
378
|
+
if (m.disabled) continue; // unavailable on this key: never offer it
|
|
225
379
|
const efforts = m.reasoning_efforts ?? [];
|
|
226
380
|
const entry: ConfigModel = {
|
|
227
381
|
name: m.cost ? m.name : `${m.name}${UNPRICED_SUFFIX}`,
|