@sigrun/provider-bifrost 0.0.0-stage → 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/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/dist/catalog.d.ts +236 -0
- package/dist/catalog.js +448 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +161 -0
- package/dist/http.d.ts +17 -0
- package/dist/http.js +88 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +280 -0
- package/package.json +52 -5
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog composition (ported from pi-bifrost-gateway 0.3.0 @ 5d15a28,
|
|
3
|
+
* `src/catalog.ts` + the constants of its `src/bifrost.ts`): wire schemas
|
|
4
|
+
* and fetching, chain decoding from routing-rule CELs, native-family
|
|
5
|
+
* surface selection, TEXT_ONLY denylist scoping, and the pure model
|
|
6
|
+
* builders. Every published model is stamped `provider: "bifrost"` —
|
|
7
|
+
* pi-ai's restore path drops stored models whose `provider` differs from
|
|
8
|
+
* the registered id, so an unstamped catalog silently vanishes on cold
|
|
9
|
+
* boot (restore-filter spike, Review Focus #3).
|
|
10
|
+
*/
|
|
11
|
+
// Type re-exported by pi-ai (its own dependency); Compile/Validator come
|
|
12
|
+
// from typebox directly — typebox co-resolves with pi-ai everywhere pi-ai
|
|
13
|
+
// resolves (it is pi-ai's own dependency), so the build-time-only devDep
|
|
14
|
+
// posture is unchanged.
|
|
15
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
16
|
+
import { Compile } from "typebox/compile";
|
|
17
|
+
import { fetchJson } from "./http.js";
|
|
18
|
+
/* ---------- gateway constants (pi port's src/bifrost.ts) ---------- */
|
|
19
|
+
/** The provider id models are registered (and stamped) under. */
|
|
20
|
+
export const PROVIDER_ID = "bifrost";
|
|
21
|
+
export const PROVIDER_DISPLAY_NAME = "Bifrost Gateway";
|
|
22
|
+
/** Different relative to the configured /v1 base URL. */
|
|
23
|
+
export const INFERENCE_PATH_MODELS = "models";
|
|
24
|
+
/** Management API paths (live-verified on Bifrost v2.2.x, 2026-10-08). */
|
|
25
|
+
export const MANAGEMENT_PATH_VIRTUAL_KEYS = "/api/governance/virtual-keys";
|
|
26
|
+
export const MANAGEMENT_PATH_ROUTING_RULES = "/api/routing/rules";
|
|
27
|
+
/**
|
|
28
|
+
* Neutral createProvider-shape baseUrl used before a URL exists from any
|
|
29
|
+
* source. NEVER requested: every published model carries its own
|
|
30
|
+
* discovery-time baseUrl, and discovery refuses to run without a real URL
|
|
31
|
+
* (unconfigured throws). An install in this state publishes zero models.
|
|
32
|
+
*/
|
|
33
|
+
export const UNRESOLVED_BASE_URL = "http://localhost:8080/v1";
|
|
34
|
+
/* ---------- thinking ladder ---------- */
|
|
35
|
+
/**
|
|
36
|
+
* pi thinking levels, ladder order; `off` (the map's disable key) comes from
|
|
37
|
+
* ModelThinkingLevel, not this ladder. Chain models are transport-only:
|
|
38
|
+
* effort routing happens through the gateway, so every chain advertises the
|
|
39
|
+
* full map and the client sends reasoning_effort untouched.
|
|
40
|
+
*/
|
|
41
|
+
const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
|
42
|
+
/**
|
|
43
|
+
* Input modalities published for models this plugin serves.
|
|
44
|
+
*
|
|
45
|
+
* Bifrost's /v1/models wire carries no modality metadata, so per-model truth
|
|
46
|
+
* is unknowable at catalog-refresh time. Declaring text-only is the worse
|
|
47
|
+
* error: pi-ai gates TOOL-RESULT images on model.input and silently replaces
|
|
48
|
+
* them with "(see attached image)" — a silent data-loss path (user-message
|
|
49
|
+
* images are forwarded regardless). Declaring image lets tool-result images
|
|
50
|
+
* through; a genuinely text-only upstream then 400s and Bifrost fails over
|
|
51
|
+
* to the next chain member, which is visible instead of lossy. This is the
|
|
52
|
+
* same trade-off as the reasoning:true invariant: false capability is
|
|
53
|
+
* recoverable, hidden capability is not.
|
|
54
|
+
*/
|
|
55
|
+
const INPUT_MODALITIES = ["text", "image"];
|
|
56
|
+
const TEXT_ONLY_MODALITIES = ["text"];
|
|
57
|
+
/**
|
|
58
|
+
* Model families with NO image input anywhere in the serving fleet
|
|
59
|
+
* (user ruling 2026-10-16; evidence in llm-catalogue vision-provider-grid.md).
|
|
60
|
+
* EXACT bare-name match after stripping the managed prefixes — substring
|
|
61
|
+
* matching would wrongly sweep vision-capable siblings: glm-5.3-flash is
|
|
62
|
+
* vision-capable first-party, deepseek-v4.1-flash likewise; only the bare
|
|
63
|
+
* glm-5.3 / deepseek-v4-flash / deepseek-v4-pro are text-only. hyper's
|
|
64
|
+
* glm-5.3 additionally SILENT-DROPS images (accepts HTTP 200, ignores the
|
|
65
|
+
* pixels) — the worst mode, because no 400 means no failover.
|
|
66
|
+
*/
|
|
67
|
+
const TEXT_ONLY_MODELS = new Set([
|
|
68
|
+
"glm-5.3",
|
|
69
|
+
"deepseek-v4-flash",
|
|
70
|
+
"deepseek-v4-pro",
|
|
71
|
+
// upstream snapshot ids price differently but share the text-only weights
|
|
72
|
+
"deepseek-v4-flash-0731",
|
|
73
|
+
"deepseek-v4-pro-0813",
|
|
74
|
+
]);
|
|
75
|
+
/** Chain ids publish WITHOUT the provider prefix; members publish "provider/model". */
|
|
76
|
+
function bareModelName(id) {
|
|
77
|
+
const noPrefix = id.includes("/") ? id.slice(id.indexOf("/") + 1) : id;
|
|
78
|
+
// Strip legacy managed prefixes (gateway/, bifrost/) if a rule CEL ever
|
|
79
|
+
// produces a prefixed bare name; MANAGED_PREFIXES itself is private to CEL
|
|
80
|
+
// decoding, so this local strip keeps the denylist honest at both call sites.
|
|
81
|
+
return noPrefix.replace(/^(gateway|bifrost)\//, "");
|
|
82
|
+
}
|
|
83
|
+
function inputFor(id) {
|
|
84
|
+
return TEXT_ONLY_MODELS.has(bareModelName(id)) ? [...TEXT_ONLY_MODALITIES] : [...INPUT_MODALITIES];
|
|
85
|
+
}
|
|
86
|
+
export function nativeFamily(id) {
|
|
87
|
+
const name = (id.split("/").pop() ?? id).toLowerCase();
|
|
88
|
+
if (name.includes("claude"))
|
|
89
|
+
return "anthropic";
|
|
90
|
+
if (/^(gpt(?!-oss)|o\d|codex)/.test(name))
|
|
91
|
+
return "openai";
|
|
92
|
+
return "other";
|
|
93
|
+
}
|
|
94
|
+
/** Bare (last-segment) name of a model id or chain name. */
|
|
95
|
+
function bareName(id) {
|
|
96
|
+
return id.split("/").pop() ?? id;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Client wire + base URL for one model id, per settings:
|
|
100
|
+
* - nativeProtocols.messages on + (name in messagesModels list, or built-in
|
|
101
|
+
* claude heuristic when no list) → anthropic-messages at the gateway's
|
|
102
|
+
* /anthropic integration prefix (pi-ai's Anthropic SDK appends /v1/messages
|
|
103
|
+
* itself; x-api-key VK auth verified live).
|
|
104
|
+
* - nativeProtocols.responses on + (responsesModels list, or built-in gpt
|
|
105
|
+
* heuristic) → openai-responses on the /v1 base.
|
|
106
|
+
* - otherwise openai-completions — the only wire every Bifrost serves, and
|
|
107
|
+
* the gateway translates at the boundary anyway.
|
|
108
|
+
*/
|
|
109
|
+
export function surfaceFor(id, baseUrl, native) {
|
|
110
|
+
if (native?.messages) {
|
|
111
|
+
const name = bareName(id).toLowerCase();
|
|
112
|
+
const match = native.messagesModels
|
|
113
|
+
? native.messagesModels.some((m) => m.toLowerCase() === name)
|
|
114
|
+
: nativeFamily(id) === "anthropic";
|
|
115
|
+
if (match)
|
|
116
|
+
return { api: "anthropic-messages", baseUrl: `${managementBase(baseUrl)}/anthropic` };
|
|
117
|
+
}
|
|
118
|
+
if (native?.responses) {
|
|
119
|
+
const name = bareName(id).toLowerCase();
|
|
120
|
+
const match = native.responsesModels
|
|
121
|
+
? native.responsesModels.some((m) => m.toLowerCase() === name)
|
|
122
|
+
: nativeFamily(id) === "openai";
|
|
123
|
+
if (match)
|
|
124
|
+
return { api: "openai-responses", baseUrl };
|
|
125
|
+
}
|
|
126
|
+
return { api: "openai-completions", baseUrl };
|
|
127
|
+
}
|
|
128
|
+
const CONTEXT_WINDOW_FALLBACK = 128_000;
|
|
129
|
+
const MAX_TOKENS_FALLBACK = 8_192;
|
|
130
|
+
/* ---------- wire schemas (typebox-validated) ---------- */
|
|
131
|
+
const PricingSchema = Type.Object({
|
|
132
|
+
prompt: Type.String(),
|
|
133
|
+
completion: Type.String(),
|
|
134
|
+
input_cache_read: Type.Optional(Type.String()),
|
|
135
|
+
input_cache_write: Type.Optional(Type.String()),
|
|
136
|
+
});
|
|
137
|
+
export const CatalogModelSchema = Type.Object({
|
|
138
|
+
id: Type.String(),
|
|
139
|
+
owned_by: Type.Optional(Type.String()),
|
|
140
|
+
context_length: Type.Optional(Type.Number()),
|
|
141
|
+
max_output_limit: Type.Optional(Type.Number()),
|
|
142
|
+
pricing: Type.Optional(PricingSchema),
|
|
143
|
+
});
|
|
144
|
+
export const ModelsEnvelopeSchema = Type.Object({
|
|
145
|
+
data: Type.Array(CatalogModelSchema),
|
|
146
|
+
});
|
|
147
|
+
export const RoutingTargetSchema = Type.Object({
|
|
148
|
+
provider: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
149
|
+
model: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
150
|
+
weight: Type.Number(),
|
|
151
|
+
});
|
|
152
|
+
export const RoutingRuleSchema = Type.Object({
|
|
153
|
+
id: Type.String(),
|
|
154
|
+
name: Type.String(),
|
|
155
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
156
|
+
cel_expression: Type.String(),
|
|
157
|
+
targets: Type.Array(RoutingTargetSchema),
|
|
158
|
+
fallbacks: Type.Optional(Type.Array(Type.String())),
|
|
159
|
+
scope: Type.String(),
|
|
160
|
+
scope_id: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
161
|
+
priority: Type.Optional(Type.Number()),
|
|
162
|
+
});
|
|
163
|
+
export const RulesEnvelopeSchema = Type.Object({
|
|
164
|
+
rules: Type.Array(RoutingRuleSchema),
|
|
165
|
+
});
|
|
166
|
+
export const VirtualKeySchema = Type.Object({
|
|
167
|
+
id: Type.String(),
|
|
168
|
+
name: Type.Optional(Type.String()),
|
|
169
|
+
value: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
170
|
+
});
|
|
171
|
+
/**
|
|
172
|
+
* Wire envelope as observed 2026-10-08: GET /api/governance/virtual-keys
|
|
173
|
+
* returns `{virtual_keys: [...], count, ...}` with the full VK rows
|
|
174
|
+
* INCLUDING their secret values. Unknown extra fields pass validation.
|
|
175
|
+
*/
|
|
176
|
+
export const VirtualKeysEnvelopeSchema = Type.Object({
|
|
177
|
+
virtual_keys: Type.Array(VirtualKeySchema),
|
|
178
|
+
});
|
|
179
|
+
const ModelsEnvelopeValidator = Compile(ModelsEnvelopeSchema);
|
|
180
|
+
const RulesEnvelopeValidator = Compile(RulesEnvelopeSchema);
|
|
181
|
+
const VirtualKeysEnvelopeValidator = Compile(VirtualKeysEnvelopeSchema);
|
|
182
|
+
/* ---------- wire fetching ---------- */
|
|
183
|
+
/** Decode an OpenAI-shaped /v1/models wire envelope into member entries. */
|
|
184
|
+
export function wireCatalogToModels(envelope) {
|
|
185
|
+
return envelope.data;
|
|
186
|
+
}
|
|
187
|
+
/** GET gateway /v1/models with the VK — the full member catalog with pricing. */
|
|
188
|
+
export async function wireCatalog(token, baseUrl, signal, opts) {
|
|
189
|
+
const envelope = await fetchJson(`${baseUrl.replace(/\/$/, "")}/${INFERENCE_PATH_MODELS}`, token, ModelsEnvelopeValidator, signal, opts);
|
|
190
|
+
return wireCatalogToModels(envelope);
|
|
191
|
+
}
|
|
192
|
+
/** Resolve the management-plane base URL from the inference /v1 base URL. */
|
|
193
|
+
export function managementBase(baseUrl) {
|
|
194
|
+
return baseUrl.replace(/\/v1\/?$/, "");
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* GET /api/governance/virtual-keys and match the INFERENCE credential value
|
|
198
|
+
* (the VK) against VK values to discover this plugin's VK id (the scope_id
|
|
199
|
+
* for routing-rule filtering). AUTH on the call is the MANAGEMENT token
|
|
200
|
+
* (settings managementApiKey, falling back to the VK — the split matters on
|
|
201
|
+
* deployments whose management plane expects a different credential than
|
|
202
|
+
* inference). Returns undefined when no VK row's value matches vkValue.
|
|
203
|
+
*/
|
|
204
|
+
export async function wireScopeId(managementToken, vkValue, baseUrl, signal, opts) {
|
|
205
|
+
const url = `${managementBase(baseUrl)}${MANAGEMENT_PATH_VIRTUAL_KEYS}`;
|
|
206
|
+
const envelope = await fetchJson(url, managementToken, VirtualKeysEnvelopeValidator, signal, opts);
|
|
207
|
+
const keys = envelope.virtual_keys;
|
|
208
|
+
const match = keys.find((vk) => vk.value === vkValue);
|
|
209
|
+
return match?.id;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* GET /api/routing/rules?scope=virtual_key&scope_id=<id> and decode the
|
|
213
|
+
* chain rules. AUTH is the MANAGEMENT token (see wireScopeId). Throws when
|
|
214
|
+
* Bifrost responds with an error envelope (schema mismatch or HTTP error
|
|
215
|
+
* surfaced by fetchJson).
|
|
216
|
+
*/
|
|
217
|
+
export async function wireRules(managementToken, baseUrl, scopeId, signal, opts) {
|
|
218
|
+
const url = `${managementBase(baseUrl)}${MANAGEMENT_PATH_ROUTING_RULES}?scope=virtual_key&scope_id=${encodeURIComponent(scopeId)}`;
|
|
219
|
+
const envelope = await fetchJson(url, managementToken, RulesEnvelopeValidator, signal, opts);
|
|
220
|
+
return envelope.rules;
|
|
221
|
+
}
|
|
222
|
+
/** Full discovery: catalog + rules for the VK, in parallel where possible.
|
|
223
|
+
* AUTH: management plane (VK lookup + rules) uses managementToken; the
|
|
224
|
+
* inference catalog uses the VK itself. */
|
|
225
|
+
export async function wireDiscovery(managementToken, vkValue, baseUrl, scopeId, signal, opts) {
|
|
226
|
+
const [catalog, rules] = await Promise.all([
|
|
227
|
+
wireCatalog(vkValue, baseUrl, signal, opts),
|
|
228
|
+
wireRules(managementToken, baseUrl, scopeId, signal, opts),
|
|
229
|
+
]);
|
|
230
|
+
return { catalog, rules };
|
|
231
|
+
}
|
|
232
|
+
/* ---------- chain decoding (pure) ---------- */
|
|
233
|
+
/** Managed model-id prefixes that strip to the bare chain name. */
|
|
234
|
+
const MANAGED_PREFIXES = ["bifrost", "gateway"];
|
|
235
|
+
/**
|
|
236
|
+
* Extract a chain model name from a routing rule's CEL expression.
|
|
237
|
+
* Recognizes `model == "x"` arms and strips managed prefixes (`bifrost/`,
|
|
238
|
+
* legacy `gateway/`) from each name. Every arm must reduce to one canonical
|
|
239
|
+
* bare name — the chains-first convention. Anything else is not a chain rule.
|
|
240
|
+
*/
|
|
241
|
+
export function chainNameFromCel(cel) {
|
|
242
|
+
const names = [...cel.matchAll(/model\s*==\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
243
|
+
if (names.length < 2)
|
|
244
|
+
return undefined;
|
|
245
|
+
const canonical = new Set(names.map((n) => {
|
|
246
|
+
const idx = n.indexOf("/");
|
|
247
|
+
const prefix = idx > 0 ? n.slice(0, idx) : "";
|
|
248
|
+
return MANAGED_PREFIXES.includes(prefix) ? n.slice(idx + 1) : n;
|
|
249
|
+
}));
|
|
250
|
+
return canonical.size === 1 ? [...canonical][0] : undefined;
|
|
251
|
+
}
|
|
252
|
+
/** Decode enabled, unambiguous chain rules. Order follows rule priority. */
|
|
253
|
+
export function wireRulesToChains(rules) {
|
|
254
|
+
const usable = [];
|
|
255
|
+
for (const rule of rules) {
|
|
256
|
+
if (rule.enabled === false)
|
|
257
|
+
continue;
|
|
258
|
+
const name = chainNameFromCel(rule.cel_expression);
|
|
259
|
+
if (!name)
|
|
260
|
+
continue;
|
|
261
|
+
const primaries = new Set(rule.targets.map((t) => {
|
|
262
|
+
if (typeof t.model !== "string" || t.model.length === 0)
|
|
263
|
+
return null;
|
|
264
|
+
return typeof t.provider === "string" && t.provider.length > 0 ? `${t.provider}/${t.model}` : t.model;
|
|
265
|
+
}).filter((m) => typeof m === "string"));
|
|
266
|
+
if (primaries.size !== 1)
|
|
267
|
+
continue; // zero or multiple pinned models = ambiguous
|
|
268
|
+
const primary = [...primaries][0];
|
|
269
|
+
usable.push({
|
|
270
|
+
chain: { name, primary, fallbacks: rule.fallbacks ?? [] },
|
|
271
|
+
priority: rule.priority ?? 0,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
usable.sort((a, b) => a.priority - b.priority);
|
|
275
|
+
return usable.map((entry) => entry.chain);
|
|
276
|
+
}
|
|
277
|
+
/* ---------- model building (pure) ---------- */
|
|
278
|
+
/** Convert a $/token wire string to pi's $/Mtok rate. */
|
|
279
|
+
export function perMtok(dollarsPerToken) {
|
|
280
|
+
const value = Number.parseFloat(dollarsPerToken);
|
|
281
|
+
return Number.isFinite(value) && value > 0 ? value * 1_000_000 : undefined;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The published map ships the full ladder for transport surfaces (chains are
|
|
285
|
+
* transport-only: the gateway routes effort). A direct member with a
|
|
286
|
+
* settings-declared subset gets exactly that subset — pi-ai disables levels
|
|
287
|
+
* it sends for a model.
|
|
288
|
+
*
|
|
289
|
+
* Per-surface `off` values (pi-inferhub, live-verified 2026-10-18):
|
|
290
|
+
* - openai-completions: "off" — gateway-normalized passthrough.
|
|
291
|
+
* - openai-responses: "none" — [OI] reasoning-off is effort "none"; "off" is
|
|
292
|
+
* accepted but leaves the effort unset.
|
|
293
|
+
* - anthropic-messages: null — these Claude models reject
|
|
294
|
+
* thinking:{type:"disabled"} (400); null makes pi-ai omit the field.
|
|
295
|
+
* Level keys stay absent so the client keeps every level selectable
|
|
296
|
+
* (optimistic by design) — the Anthropic effort ladder has no "minimal",
|
|
297
|
+
* so the completions ladder would advertise a level pi-ai cannot express
|
|
298
|
+
* on this surface.
|
|
299
|
+
*/
|
|
300
|
+
function thinkingLevelMap(api, levels) {
|
|
301
|
+
if (api === "anthropic-messages")
|
|
302
|
+
return { off: null };
|
|
303
|
+
if (api === "openai-responses") {
|
|
304
|
+
const map = { off: "none" };
|
|
305
|
+
for (const level of levels ?? PI_THINKING_LEVELS)
|
|
306
|
+
map[level] = level;
|
|
307
|
+
return map;
|
|
308
|
+
}
|
|
309
|
+
const ladder = levels ?? PI_THINKING_LEVELS;
|
|
310
|
+
const map = { off: "off" };
|
|
311
|
+
for (const level of ladder)
|
|
312
|
+
map[level] = level;
|
|
313
|
+
return map;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Build one bifrost/<chain> chat model per chain. Cost is 0 by design. The
|
|
317
|
+
* wire follows the chain's model-family (native protocol end to end): the
|
|
318
|
+
* chain name decides — fallback legs of a chain are same-model by
|
|
319
|
+
* construction, and the gateway translates any client wire for legs that
|
|
320
|
+
* differ.
|
|
321
|
+
*/
|
|
322
|
+
export function buildChainModels(chains, catalog, providerSlug, baseUrl, native) {
|
|
323
|
+
const byId = new Map(catalog.map((m) => [m.id, m]));
|
|
324
|
+
return chains.map((chain) => {
|
|
325
|
+
const members = [chain.primary, ...chain.fallbacks];
|
|
326
|
+
const contexts = members
|
|
327
|
+
.map((id) => byId.get(id)?.context_length)
|
|
328
|
+
.filter((v) => typeof v === "number");
|
|
329
|
+
const contextWindow = contexts.length > 0 ? Math.min(...contexts) : CONTEXT_WINDOW_FALLBACK;
|
|
330
|
+
return buildModel({
|
|
331
|
+
id: `${providerSlug}/${chain.name}`,
|
|
332
|
+
name: chain.name,
|
|
333
|
+
familyId: chain.name,
|
|
334
|
+
provider: providerSlug,
|
|
335
|
+
baseUrl,
|
|
336
|
+
native,
|
|
337
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
338
|
+
contextWindow,
|
|
339
|
+
maxTokens: MAX_TOKENS_FALLBACK,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
/** Shared field assembly for both builders; the surface (api/baseUrl/map/
|
|
344
|
+
* compat) derives from the model NAME's native family. */
|
|
345
|
+
function buildModel(args) {
|
|
346
|
+
const surface = surfaceFor(args.familyId, args.baseUrl, args.native);
|
|
347
|
+
const common = {
|
|
348
|
+
id: args.id,
|
|
349
|
+
name: args.name,
|
|
350
|
+
provider: args.provider,
|
|
351
|
+
input: [...inputFor(args.familyId)],
|
|
352
|
+
cost: args.cost,
|
|
353
|
+
reasoning: true,
|
|
354
|
+
contextWindow: args.contextWindow,
|
|
355
|
+
maxTokens: args.maxTokens,
|
|
356
|
+
};
|
|
357
|
+
if (surface.api === "anthropic-messages") {
|
|
358
|
+
const model = {
|
|
359
|
+
...common,
|
|
360
|
+
api: surface.api,
|
|
361
|
+
baseUrl: surface.baseUrl,
|
|
362
|
+
thinkingLevelMap: thinkingLevelMap(surface.api),
|
|
363
|
+
// These Claude models reject thinking:{type:"disabled"} (400,
|
|
364
|
+
// live-verified in pi-inferhub 2026-10-18) — always adaptive.
|
|
365
|
+
compat: { forceAdaptiveThinking: true },
|
|
366
|
+
};
|
|
367
|
+
return model;
|
|
368
|
+
}
|
|
369
|
+
if (surface.api === "openai-responses") {
|
|
370
|
+
const model = {
|
|
371
|
+
...common,
|
|
372
|
+
api: surface.api,
|
|
373
|
+
baseUrl: surface.baseUrl,
|
|
374
|
+
thinkingLevelMap: thinkingLevelMap(surface.api, args.thinkingLevels),
|
|
375
|
+
};
|
|
376
|
+
return model;
|
|
377
|
+
}
|
|
378
|
+
const model = {
|
|
379
|
+
...common,
|
|
380
|
+
api: surface.api,
|
|
381
|
+
baseUrl: surface.baseUrl,
|
|
382
|
+
thinkingLevelMap: thinkingLevelMap(surface.api, args.thinkingLevels),
|
|
383
|
+
};
|
|
384
|
+
return model;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Build direct-selectable models for the /v1/models catalog with real
|
|
388
|
+
* per-member pricing, ordered with chain members first.
|
|
389
|
+
*/
|
|
390
|
+
export function buildMemberModels(chains, catalog, providerSlug, baseUrl, thinkingOverrides, native) {
|
|
391
|
+
const memberIds = [];
|
|
392
|
+
const seen = new Set();
|
|
393
|
+
for (const chain of chains) {
|
|
394
|
+
for (const id of [chain.primary, ...chain.fallbacks]) {
|
|
395
|
+
if (!seen.has(id)) {
|
|
396
|
+
seen.add(id);
|
|
397
|
+
memberIds.push(id);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const byId = new Map(catalog.map((m) => [m.id, m]));
|
|
402
|
+
for (const entry of catalog) {
|
|
403
|
+
if (!seen.has(entry.id)) {
|
|
404
|
+
seen.add(entry.id);
|
|
405
|
+
memberIds.push(entry.id);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const models = [];
|
|
409
|
+
for (const memberId of memberIds) {
|
|
410
|
+
const entry = byId.get(memberId);
|
|
411
|
+
if (!entry)
|
|
412
|
+
continue; // chain member absent from /v1/models — skip rather than guess
|
|
413
|
+
const inputRate = entry.pricing ? perMtok(entry.pricing.prompt) : undefined;
|
|
414
|
+
const outputRate = entry.pricing ? perMtok(entry.pricing.completion) : undefined;
|
|
415
|
+
// The gateway reports cache rates when its upstream pricing carries
|
|
416
|
+
// them; a missing or zero rate becomes cost 0 (HONEST-zero rule —
|
|
417
|
+
// never fabricated), so member cache-read costs read free until
|
|
418
|
+
// Bifrost's datasheet is populated.
|
|
419
|
+
const cacheReadRate = entry.pricing?.input_cache_read ? perMtok(entry.pricing.input_cache_read) : undefined;
|
|
420
|
+
const cacheWriteRate = entry.pricing?.input_cache_write ? perMtok(entry.pricing.input_cache_write) : undefined;
|
|
421
|
+
models.push(buildModel({
|
|
422
|
+
id: memberId,
|
|
423
|
+
name: memberId,
|
|
424
|
+
familyId: memberId,
|
|
425
|
+
provider: providerSlug,
|
|
426
|
+
baseUrl,
|
|
427
|
+
native,
|
|
428
|
+
cost: {
|
|
429
|
+
input: inputRate ?? 0,
|
|
430
|
+
output: outputRate ?? 0,
|
|
431
|
+
cacheRead: cacheReadRate ?? 0,
|
|
432
|
+
cacheWrite: cacheWriteRate ?? 0,
|
|
433
|
+
},
|
|
434
|
+
contextWindow: entry.context_length ?? CONTEXT_WINDOW_FALLBACK,
|
|
435
|
+
maxTokens: entry.max_output_limit ?? MAX_TOKENS_FALLBACK,
|
|
436
|
+
thinkingLevels: thinkingOverrides?.[memberId],
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
return models;
|
|
440
|
+
}
|
|
441
|
+
/** Chains first, then upstream models (deduped by id — chain ids are namespaced). */
|
|
442
|
+
export function buildAllModels(chains, catalog, providerSlug, baseUrl, publishChains, publishUpstream, thinkingOverrides, native) {
|
|
443
|
+
const chainModels = publishChains ? buildChainModels(chains, catalog, providerSlug, baseUrl, native) : [];
|
|
444
|
+
const upstreamModels = publishUpstream
|
|
445
|
+
? buildMemberModels(chains, catalog, providerSlug, baseUrl, thinkingOverrides, native)
|
|
446
|
+
: [];
|
|
447
|
+
return [...chainModels, ...upstreamModels];
|
|
448
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration (ported from pi-bifrost-gateway 0.3.0 @ 5d15a28,
|
|
3
|
+
* `src/config.ts`): the SAME key surface, but sourced from the plugin's
|
|
4
|
+
* SCOPED SETTINGS (`settings/provider-bifrost.json` in the sigrun config
|
|
5
|
+
* dir, read ONCE at factory time — restart-required) instead of pi's
|
|
6
|
+
* config file. Parsing is pure — `parseBifrostSettings(raw, issues)`
|
|
7
|
+
* takes the settings object the store returns; wrong-typed values and
|
|
8
|
+
* unknown keys collect issues the factory logs, never fatal (identical
|
|
9
|
+
* posture to the pi port).
|
|
10
|
+
*
|
|
11
|
+
* DEFAULTS ARE REVERSED FROM PI (user ruling, provider-plugins spec):
|
|
12
|
+
* `publishChains` defaults FALSE — the conservative stock-gateway shape —
|
|
13
|
+
* but `publishUpstream` defaults TRUE, because sigrun (unlike pi) has no
|
|
14
|
+
* interactive /login and a member-only install is the no-management-plane
|
|
15
|
+
* deployment shape. pi 0.3.0 ships the same publishChains default and the
|
|
16
|
+
* same publishUpstream default; the "reversal" is vs pi 0.2.x's
|
|
17
|
+
* publishChains:true.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Native-protocol surface selection (client-side wire choice per model
|
|
21
|
+
* family). Both toggles default false — every model publishes as chat
|
|
22
|
+
* completions, the only wire every Bifrost serves. When a toggle is on,
|
|
23
|
+
* models matching the BUILT-IN family heuristic publish that native wire;
|
|
24
|
+
* when the matching exact-name list is ALSO set, the list REPLACES the
|
|
25
|
+
* built-in heuristic for that surface (list membership is the only path to
|
|
26
|
+
* the native wire).
|
|
27
|
+
*/
|
|
28
|
+
export interface NativeProtocols {
|
|
29
|
+
/** gpt-family models publish openai-responses (gateway must serve /v1/responses). */
|
|
30
|
+
responses: boolean;
|
|
31
|
+
/** claude-family models publish anthropic-messages (gateway must serve the /anthropic integration route). */
|
|
32
|
+
messages: boolean;
|
|
33
|
+
/** Exact bare model names ("gpt-6-luna") for the responses surface; replaces the built-in gpt heuristic. */
|
|
34
|
+
responsesModels?: string[];
|
|
35
|
+
/** Exact bare model names ("claude-sonnet-5-5") for the messages surface; replaces the built-in claude heuristic. */
|
|
36
|
+
messagesModels?: string[];
|
|
37
|
+
}
|
|
38
|
+
export interface BifrostSettings {
|
|
39
|
+
/** Emit chain models (bifrost/<chain>) from the VK's routing rules. */
|
|
40
|
+
publishChains: boolean;
|
|
41
|
+
/** Emit all upstream models from /v1/models as direct-selectable entries. */
|
|
42
|
+
publishUpstream: boolean;
|
|
43
|
+
/** Explicit gateway /v1 base URL; when set it wins over the env fallback. */
|
|
44
|
+
baseUrl?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Per-member thinking-level overrides, keyed by upstream member id
|
|
47
|
+
* ("provider/model"). Each list is the member's real supported ladder;
|
|
48
|
+
* when present it REPLACES the full-ladder default on direct-selectable
|
|
49
|
+
* member models of that id.
|
|
50
|
+
*/
|
|
51
|
+
thinkingOverrides?: Record<string, string[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Bearer for the management plane (/api/governance/virtual-keys,
|
|
54
|
+
* /api/routing/rules). Falls back to the inference credential (the VK)
|
|
55
|
+
* when unset — the right shape for dashboard-auth-disabled deployments
|
|
56
|
+
* where the VK itself is accepted on /api/*. Set it on deployments whose
|
|
57
|
+
* management plane expects a different credential than inference.
|
|
58
|
+
*/
|
|
59
|
+
managementApiKey?: string;
|
|
60
|
+
/** Native-protocol surface gates (both default false — chat completions everywhere). */
|
|
61
|
+
nativeProtocols?: NativeProtocols;
|
|
62
|
+
}
|
|
63
|
+
/** Ambient env key for the gateway URL — resolved at credential-resolution time
|
|
64
|
+
* through the env seam (see `src/index.ts`); the settings `baseUrl` outranks it. */
|
|
65
|
+
export declare const BIFROST_BASE_URL_ENV = "BIFROST_BASE_URL";
|
|
66
|
+
declare const THINKING_LEVELS: readonly ["minimal", "low", "medium", "high", "xhigh", "max"];
|
|
67
|
+
export type ThinkingLevelName = (typeof THINKING_LEVELS)[number];
|
|
68
|
+
/**
|
|
69
|
+
* Parse the plugin's scoped-settings object. Missing settings = defaults;
|
|
70
|
+
* unknown keys and wrong-typed values collect issues the caller logs but
|
|
71
|
+
* that are never fatal.
|
|
72
|
+
*
|
|
73
|
+
* Defaults: publishChains FALSE, publishUpstream TRUE (see the module
|
|
74
|
+
* comment — pi 0.3.0 parity on both).
|
|
75
|
+
*/
|
|
76
|
+
export declare function parseBifrostSettings(raw: unknown, issues: string[]): BifrostSettings;
|
|
77
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration (ported from pi-bifrost-gateway 0.3.0 @ 5d15a28,
|
|
3
|
+
* `src/config.ts`): the SAME key surface, but sourced from the plugin's
|
|
4
|
+
* SCOPED SETTINGS (`settings/provider-bifrost.json` in the sigrun config
|
|
5
|
+
* dir, read ONCE at factory time — restart-required) instead of pi's
|
|
6
|
+
* config file. Parsing is pure — `parseBifrostSettings(raw, issues)`
|
|
7
|
+
* takes the settings object the store returns; wrong-typed values and
|
|
8
|
+
* unknown keys collect issues the factory logs, never fatal (identical
|
|
9
|
+
* posture to the pi port).
|
|
10
|
+
*
|
|
11
|
+
* DEFAULTS ARE REVERSED FROM PI (user ruling, provider-plugins spec):
|
|
12
|
+
* `publishChains` defaults FALSE — the conservative stock-gateway shape —
|
|
13
|
+
* but `publishUpstream` defaults TRUE, because sigrun (unlike pi) has no
|
|
14
|
+
* interactive /login and a member-only install is the no-management-plane
|
|
15
|
+
* deployment shape. pi 0.3.0 ships the same publishChains default and the
|
|
16
|
+
* same publishUpstream default; the "reversal" is vs pi 0.2.x's
|
|
17
|
+
* publishChains:true.
|
|
18
|
+
*/
|
|
19
|
+
/** Ambient env key for the gateway URL — resolved at credential-resolution time
|
|
20
|
+
* through the env seam (see `src/index.ts`); the settings `baseUrl` outranks it. */
|
|
21
|
+
export const BIFROST_BASE_URL_ENV = "BIFROST_BASE_URL";
|
|
22
|
+
const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
|
23
|
+
const VALID_LEVELS = new Set(THINKING_LEVELS);
|
|
24
|
+
function parseThinkingOverrides(raw, issues) {
|
|
25
|
+
if (raw === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
28
|
+
issues.push("thinkingOverrides must be an object of member-id → allowed levels; ignoring");
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const overrides = {};
|
|
32
|
+
for (const [memberId, levels] of Object.entries(raw)) {
|
|
33
|
+
if (!Array.isArray(levels)) {
|
|
34
|
+
issues.push(`thinkingOverrides["${memberId}"] must be an array of level strings; ignoring`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const valid = levels.filter((l) => typeof l === "string" && VALID_LEVELS.has(l));
|
|
38
|
+
if (valid.length !== levels.length) {
|
|
39
|
+
// A ladder the author didn't fully know must not be half-applied:
|
|
40
|
+
// the member keeps the full-map default until the override is
|
|
41
|
+
// corrected.
|
|
42
|
+
issues.push(`thinkingOverrides["${memberId}"] contains unknown level names; entry ignored`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (valid.length > 0)
|
|
46
|
+
overrides[memberId] = valid;
|
|
47
|
+
}
|
|
48
|
+
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
|
49
|
+
}
|
|
50
|
+
function parseNativeProtocols(raw, issues) {
|
|
51
|
+
if (raw === undefined)
|
|
52
|
+
return undefined;
|
|
53
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
54
|
+
issues.push("nativeProtocols must be an object; ignoring");
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const obj = raw;
|
|
58
|
+
const out = { responses: false, messages: false };
|
|
59
|
+
let seen = false;
|
|
60
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
61
|
+
switch (key) {
|
|
62
|
+
case "responses":
|
|
63
|
+
case "messages":
|
|
64
|
+
if (typeof value === "boolean") {
|
|
65
|
+
out[key] = value;
|
|
66
|
+
seen = true;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
issues.push(`nativeProtocols.${key} must be a boolean (got ${typeof value}); ignoring`);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
case "responsesModels":
|
|
73
|
+
case "messagesModels": {
|
|
74
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string" && v.trim().length > 0)) {
|
|
75
|
+
issues.push(`nativeProtocols.${key} must be an array of non-empty model names; ignoring`);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
const names = value.map((v) => v.trim());
|
|
79
|
+
if (new Set(names).size !== names.length) {
|
|
80
|
+
issues.push(`nativeProtocols.${key} contains duplicates; ignoring the list`);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
out[key] = names;
|
|
84
|
+
seen = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
issues.push(`unknown nativeProtocols key "${key}"`);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return seen ? out : undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Parse the plugin's scoped-settings object. Missing settings = defaults;
|
|
96
|
+
* unknown keys and wrong-typed values collect issues the caller logs but
|
|
97
|
+
* that are never fatal.
|
|
98
|
+
*
|
|
99
|
+
* Defaults: publishChains FALSE, publishUpstream TRUE (see the module
|
|
100
|
+
* comment — pi 0.3.0 parity on both).
|
|
101
|
+
*/
|
|
102
|
+
export function parseBifrostSettings(raw, issues) {
|
|
103
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
104
|
+
// An absent settings file reads as {} (the store's contract), so a
|
|
105
|
+
// non-object here means a malformed file — defaults + issue.
|
|
106
|
+
issues.push("settings must be a JSON object; using defaults");
|
|
107
|
+
return { publishChains: false, publishUpstream: true };
|
|
108
|
+
}
|
|
109
|
+
const obj = raw;
|
|
110
|
+
let publishChains = false;
|
|
111
|
+
let publishUpstream;
|
|
112
|
+
let baseUrl;
|
|
113
|
+
let thinkingOverrides;
|
|
114
|
+
let managementApiKey;
|
|
115
|
+
let nativeProtocols;
|
|
116
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
117
|
+
switch (key) {
|
|
118
|
+
case "publishChains":
|
|
119
|
+
if (typeof value === "boolean")
|
|
120
|
+
publishChains = value;
|
|
121
|
+
else
|
|
122
|
+
issues.push(`publishChains must be a boolean (got ${typeof value}); using false`);
|
|
123
|
+
break;
|
|
124
|
+
case "publishUpstream":
|
|
125
|
+
if (typeof value === "boolean")
|
|
126
|
+
publishUpstream = value;
|
|
127
|
+
else
|
|
128
|
+
issues.push(`publishUpstream must be a boolean (got ${typeof value}); ignoring`);
|
|
129
|
+
break;
|
|
130
|
+
case "baseUrl":
|
|
131
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
132
|
+
baseUrl = value.trim();
|
|
133
|
+
else
|
|
134
|
+
issues.push(`baseUrl must be a non-empty string (got ${typeof value}); ignoring`);
|
|
135
|
+
break;
|
|
136
|
+
case "thinkingOverrides":
|
|
137
|
+
thinkingOverrides = parseThinkingOverrides(value, issues);
|
|
138
|
+
break;
|
|
139
|
+
case "managementApiKey":
|
|
140
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
141
|
+
managementApiKey = value.trim();
|
|
142
|
+
else
|
|
143
|
+
issues.push(`managementApiKey must be a non-empty string (got ${typeof value}); ignoring`);
|
|
144
|
+
break;
|
|
145
|
+
case "nativeProtocols":
|
|
146
|
+
nativeProtocols = parseNativeProtocols(value, issues);
|
|
147
|
+
break;
|
|
148
|
+
default:
|
|
149
|
+
issues.push(`unknown settings key "${key}"`);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
publishChains,
|
|
155
|
+
publishUpstream: publishUpstream ?? true,
|
|
156
|
+
baseUrl,
|
|
157
|
+
thinkingOverrides,
|
|
158
|
+
managementApiKey,
|
|
159
|
+
nativeProtocols,
|
|
160
|
+
};
|
|
161
|
+
}
|