@stablekernel/opencode-bifrost 0.2.1 → 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.1",
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[];
@@ -255,6 +259,20 @@ describe("unpriced models", () => {
255
259
  });
256
260
  });
257
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
+
258
276
  describe("reasoning", () => {
259
277
  /** A reasoning model with a discrete effort set, like Fireworks kimi-k3. */
260
278
  const REASONING: CliModel = {
@@ -394,3 +412,19 @@ describe("gateway URL allowlist", () => {
394
412
  expect(isAllowedGatewayUrl("not a url")).toBe(false);
395
413
  });
396
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] = {
@@ -226,6 +375,7 @@ export function toConfigModels(
226
375
  const out: Record<string, ConfigModel> = {};
227
376
  for (const m of payload?.models ?? []) {
228
377
  if (!m?.id) continue;
378
+ if (m.disabled) continue; // unavailable on this key: never offer it
229
379
  const efforts = m.reasoning_efforts ?? [];
230
380
  const entry: ConfigModel = {
231
381
  name: m.cost ? m.name : `${m.name}${UNPRICED_SUFFIX}`,