@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 +1 -1
- package/src/index.test.ts +34 -0
- package/src/index.ts +153 -3
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[];
|
|
@@ -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
|
-
|
|
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] = {
|
|
@@ -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}`,
|