@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stablekernel/opencode-bifrost",
3
- "version": "0.2.0",
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
- export const BifrostGateway: Plugin = async ({ directory, $ }) => {
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 = ((cfg as Record<string, any>).provider ??= {});
143
- const existing = providers[PROVIDER_ID] ?? {};
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 ? { headers: 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 ? { headers: 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}`,