@sigrun/provider-bifrost 0.1.2 → 0.2.1
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 +16 -1
- package/README.md +8 -5
- package/dist/catalog.d.ts +5 -1
- package/dist/catalog.js +12 -4
- package/dist/config.d.ts +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +22 -5
- package/dist/wire.d.ts +19 -0
- package/dist/wire.js +159 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -11,8 +11,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
11
11
|
provenance. No functional change from 0.1.0.
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
|
+
|
|
15
|
+
## [0.2.1] - 2026-11-03
|
|
16
|
+
### Fixed
|
|
17
|
+
- Replies from chain models persist the `chain/` display id, so clients can
|
|
18
|
+
show where a message came from. Outgoing history is replayed under the
|
|
19
|
+
wire name, so chain models keep their thinking blocks and signatures.
|
|
20
|
+
Usage is now keyed `bifrost/chain/<name>`; earlier usage stays under
|
|
21
|
+
`bifrost/<name>`.
|
|
22
|
+
|
|
23
|
+
## [0.2.0] - 2026-10-30
|
|
14
24
|
### Changed
|
|
15
|
-
-
|
|
25
|
+
- **Breaking:** chain model references change from `bifrost/bifrost/<chain>`
|
|
26
|
+
to `bifrost/chain/<chain>` (plugin id `chain/<chain>`). The gateway
|
|
27
|
+
receives the bare chain name.
|
|
28
|
+
- Member ids are unchanged; upstream catalog entries in the reserved
|
|
29
|
+
`chain/` display namespace are skipped with one warning per process.
|
|
30
|
+
- Forward-compatible with pi-durable 1.4.0 (widened peer range).
|
|
16
31
|
|
|
17
32
|
## [0.1.0] - 2026-10-18
|
|
18
33
|
### Changed
|
package/README.md
CHANGED
|
@@ -26,11 +26,14 @@ the provider into its `Models` store at boot.
|
|
|
26
26
|
queried too: the VK self-identifies against
|
|
27
27
|
`/api/governance/virtual-keys` (the credential value matched to VK
|
|
28
28
|
rows), then `/api/routing/rules?scope=virtual_key&scope_id=<vk>`
|
|
29
|
-
yields the chain rules; every usable chain publishes
|
|
30
|
-
`
|
|
31
|
-
|
|
29
|
+
yields the chain rules; every usable chain publishes with plugin id
|
|
30
|
+
`chain/<name>` (displayed by sigrun as `bifrost/chain/<name>`, with
|
|
31
|
+
min-member context window and cost 0 by design). Requests to the
|
|
32
|
+
gateway carry the bare `<name>`, never the `chain/` display prefix.
|
|
33
|
+
The gateway's own `/api/logs` is the billing system of record,
|
|
32
34
|
optionally followed by the upstream catalog (`publishUpstream`,
|
|
33
|
-
default true).
|
|
35
|
+
default true). Upstream `/v1/models` entries under the reserved
|
|
36
|
+
`chain/` namespace are skipped as direct members.
|
|
34
37
|
- **Readiness** (`not_configured` / `degraded` / `ready`, surfaced on
|
|
35
38
|
sigrun's `/v1/auth/check`) — seeded from credential resolution and
|
|
36
39
|
updated per refresh outcome; state transitions log via `console.warn`
|
|
@@ -126,7 +129,7 @@ and `not_configured` readiness.
|
|
|
126
129
|
```jsonc
|
|
127
130
|
// settings.json (sigrun config dir) — the UNIFIED extensions list:
|
|
128
131
|
// install + load in one place; npm and git specifiers are the same list
|
|
129
|
-
// npm ("@sigrun/provider-bifrost@^0.
|
|
132
|
+
// npm ("@sigrun/provider-bifrost@^0.2.0"), git
|
|
130
133
|
// ("git:github.com/sigrun-ai/sigrun-provider-bifrost", or any
|
|
131
134
|
// https git URL with an optional @ref), or the file: form below.
|
|
132
135
|
{
|
package/dist/catalog.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { type FetchJsonOptions } from "./http.js";
|
|
|
14
14
|
import type { NativeProtocols } from "./config.js";
|
|
15
15
|
/** The provider id models are registered (and stamped) under. */
|
|
16
16
|
export declare const PROVIDER_ID = "bifrost";
|
|
17
|
+
/** Sigrun-side chain display namespace; never sent to the gateway. */
|
|
18
|
+
export declare const CHAIN_ID_PREFIX = "chain/";
|
|
17
19
|
export declare const PROVIDER_DISPLAY_NAME = "Bifrost Gateway";
|
|
18
20
|
/** Different relative to the configured /v1 base URL. */
|
|
19
21
|
export declare const INFERENCE_PATH_MODELS = "models";
|
|
@@ -220,13 +222,15 @@ export declare function wireRulesToChains(rules: RoutingRule[]): Chain[];
|
|
|
220
222
|
/** Convert a $/token wire string to pi's $/Mtok rate. */
|
|
221
223
|
export declare function perMtok(dollarsPerToken: string): number | undefined;
|
|
222
224
|
/**
|
|
223
|
-
* Build one
|
|
225
|
+
* Build one chain/<name> display model per chain. Cost is 0 by design. The
|
|
224
226
|
* wire follows the chain's model-family (native protocol end to end): the
|
|
225
227
|
* chain name decides — fallback legs of a chain are same-model by
|
|
226
228
|
* construction, and the gateway translates any client wire for legs that
|
|
227
229
|
* differ.
|
|
228
230
|
*/
|
|
229
231
|
export declare function buildChainModels(chains: Chain[], catalog: CatalogModel[], providerSlug: string, baseUrl: string, native?: NativeProtocols): GatewayModel[];
|
|
232
|
+
/** Catalog entries that collide with the reserved chain display namespace. */
|
|
233
|
+
export declare function collidingMemberIds(catalog: readonly CatalogModel[]): string[];
|
|
230
234
|
/**
|
|
231
235
|
* Build direct-selectable models for the /v1/models catalog with real
|
|
232
236
|
* per-member pricing, ordered with chain members first.
|
package/dist/catalog.js
CHANGED
|
@@ -18,6 +18,8 @@ import { fetchJson } from "./http.js";
|
|
|
18
18
|
/* ---------- gateway constants (pi port's src/bifrost.ts) ---------- */
|
|
19
19
|
/** The provider id models are registered (and stamped) under. */
|
|
20
20
|
export const PROVIDER_ID = "bifrost";
|
|
21
|
+
/** Sigrun-side chain display namespace; never sent to the gateway. */
|
|
22
|
+
export const CHAIN_ID_PREFIX = "chain/";
|
|
21
23
|
export const PROVIDER_DISPLAY_NAME = "Bifrost Gateway";
|
|
22
24
|
/** Different relative to the configured /v1 base URL. */
|
|
23
25
|
export const INFERENCE_PATH_MODELS = "models";
|
|
@@ -78,7 +80,7 @@ function bareModelName(id) {
|
|
|
78
80
|
// Strip legacy managed prefixes (gateway/, bifrost/) if a rule CEL ever
|
|
79
81
|
// produces a prefixed bare name; MANAGED_PREFIXES itself is private to CEL
|
|
80
82
|
// decoding, so this local strip keeps the denylist honest at both call sites.
|
|
81
|
-
return noPrefix.replace(/^(gateway|bifrost)\//, "");
|
|
83
|
+
return noPrefix.replace(/^(gateway|bifrost|chain)\//, "");
|
|
82
84
|
}
|
|
83
85
|
function inputFor(id) {
|
|
84
86
|
return TEXT_ONLY_MODELS.has(bareModelName(id)) ? [...TEXT_ONLY_MODALITIES] : [...INPUT_MODALITIES];
|
|
@@ -231,7 +233,7 @@ export async function wireDiscovery(managementToken, vkValue, baseUrl, scopeId,
|
|
|
231
233
|
}
|
|
232
234
|
/* ---------- chain decoding (pure) ---------- */
|
|
233
235
|
/** Managed model-id prefixes that strip to the bare chain name. */
|
|
234
|
-
const MANAGED_PREFIXES = ["bifrost", "gateway"];
|
|
236
|
+
const MANAGED_PREFIXES = ["bifrost", "gateway", "chain"];
|
|
235
237
|
/**
|
|
236
238
|
* Extract a chain model name from a routing rule's CEL expression.
|
|
237
239
|
* Recognizes `model == "x"` arms and strips managed prefixes (`bifrost/`,
|
|
@@ -313,7 +315,7 @@ function thinkingLevelMap(api, levels) {
|
|
|
313
315
|
return map;
|
|
314
316
|
}
|
|
315
317
|
/**
|
|
316
|
-
* Build one
|
|
318
|
+
* Build one chain/<name> display model per chain. Cost is 0 by design. The
|
|
317
319
|
* wire follows the chain's model-family (native protocol end to end): the
|
|
318
320
|
* chain name decides — fallback legs of a chain are same-model by
|
|
319
321
|
* construction, and the gateway translates any client wire for legs that
|
|
@@ -328,7 +330,7 @@ export function buildChainModels(chains, catalog, providerSlug, baseUrl, native)
|
|
|
328
330
|
.filter((v) => typeof v === "number");
|
|
329
331
|
const contextWindow = contexts.length > 0 ? Math.min(...contexts) : CONTEXT_WINDOW_FALLBACK;
|
|
330
332
|
return buildModel({
|
|
331
|
-
id: `${
|
|
333
|
+
id: `${CHAIN_ID_PREFIX}${chain.name}`,
|
|
332
334
|
name: chain.name,
|
|
333
335
|
familyId: chain.name,
|
|
334
336
|
provider: providerSlug,
|
|
@@ -383,6 +385,10 @@ function buildModel(args) {
|
|
|
383
385
|
};
|
|
384
386
|
return model;
|
|
385
387
|
}
|
|
388
|
+
/** Catalog entries that collide with the reserved chain display namespace. */
|
|
389
|
+
export function collidingMemberIds(catalog) {
|
|
390
|
+
return catalog.filter((entry) => entry.id.startsWith(CHAIN_ID_PREFIX)).map((entry) => entry.id);
|
|
391
|
+
}
|
|
386
392
|
/**
|
|
387
393
|
* Build direct-selectable models for the /v1/models catalog with real
|
|
388
394
|
* per-member pricing, ordered with chain members first.
|
|
@@ -407,6 +413,8 @@ export function buildMemberModels(chains, catalog, providerSlug, baseUrl, thinki
|
|
|
407
413
|
}
|
|
408
414
|
const models = [];
|
|
409
415
|
for (const memberId of memberIds) {
|
|
416
|
+
if (memberId.startsWith(CHAIN_ID_PREFIX))
|
|
417
|
+
continue;
|
|
410
418
|
const entry = byId.get(memberId);
|
|
411
419
|
if (!entry)
|
|
412
420
|
continue; // chain member absent from /v1/models — skip rather than guess
|
package/dist/config.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface NativeProtocols {
|
|
|
36
36
|
messagesModels?: string[];
|
|
37
37
|
}
|
|
38
38
|
export interface BifrostSettings {
|
|
39
|
-
/** Emit chain models (
|
|
39
|
+
/** Emit chain models (chain/<name>) from the VK's routing rules. */
|
|
40
40
|
publishChains: boolean;
|
|
41
41
|
/** Emit all upstream models from /v1/models as direct-selectable entries. */
|
|
42
42
|
publishUpstream: boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -42,11 +42,12 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { type Provider } from "@earendil-works/pi-ai/models";
|
|
44
44
|
import { type NativeProtocols } from "./config.js";
|
|
45
|
-
import { PROVIDER_DISPLAY_NAME, PROVIDER_ID, UNRESOLVED_BASE_URL, type GatewayModel } from "./catalog.js";
|
|
46
|
-
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, UNRESOLVED_BASE_URL };
|
|
45
|
+
import { PROVIDER_DISPLAY_NAME, PROVIDER_ID, UNRESOLVED_BASE_URL, CHAIN_ID_PREFIX, type GatewayModel } from "./catalog.js";
|
|
46
|
+
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, UNRESOLVED_BASE_URL, CHAIN_ID_PREFIX };
|
|
47
47
|
export { parseBifrostSettings, BIFROST_BASE_URL_ENV } from "./config.js";
|
|
48
48
|
export type { BifrostSettings, NativeProtocols } from "./config.js";
|
|
49
49
|
export type { GatewayModel, SurfaceApi, CatalogModel, Chain, RoutingRule } from "./catalog.js";
|
|
50
|
+
export { chainWireName, withWireNames } from "./wire.js";
|
|
50
51
|
/** Ambient fallback for the virtual key when auth.json holds no credential. */
|
|
51
52
|
export declare const BIFROST_API_KEY_ENV = "BIFROST_API_KEY";
|
|
52
53
|
/**
|
package/dist/index.js
CHANGED
|
@@ -45,16 +45,25 @@ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completio
|
|
|
45
45
|
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
46
46
|
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
47
47
|
import { BIFROST_BASE_URL_ENV, parseBifrostSettings, } from "./config.js";
|
|
48
|
-
import { PROVIDER_DISPLAY_NAME, PROVIDER_ID, UNRESOLVED_BASE_URL, buildAllModels, wireCatalog, wireDiscovery, wireRulesToChains, wireScopeId, } from "./catalog.js";
|
|
48
|
+
import { PROVIDER_DISPLAY_NAME, PROVIDER_ID, UNRESOLVED_BASE_URL, CHAIN_ID_PREFIX, buildAllModels, collidingMemberIds, wireCatalog, wireDiscovery, wireRulesToChains, wireScopeId, } from "./catalog.js";
|
|
49
|
+
import { withWireNames } from "./wire.js";
|
|
49
50
|
// Re-exported gateway constants (the pi port's src/bifrost.ts surface):
|
|
50
51
|
// the provider id is part of this plugin's public contract — sigrun's
|
|
51
52
|
// auth-check names providers by id and tests pin the model stamp to it.
|
|
52
|
-
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, UNRESOLVED_BASE_URL };
|
|
53
|
+
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, UNRESOLVED_BASE_URL, CHAIN_ID_PREFIX };
|
|
53
54
|
// Re-exported discovery/config types so consumers (and tests) need no
|
|
54
55
|
// deep imports.
|
|
55
56
|
export { parseBifrostSettings, BIFROST_BASE_URL_ENV } from "./config.js";
|
|
57
|
+
export { chainWireName, withWireNames } from "./wire.js";
|
|
56
58
|
/** Ambient fallback for the virtual key when auth.json holds no credential. */
|
|
57
59
|
export const BIFROST_API_KEY_ENV = "BIFROST_API_KEY";
|
|
60
|
+
let chainNamespaceWarned = false;
|
|
61
|
+
function warnOnChainNamespaceCollision(catalog) {
|
|
62
|
+
if (chainNamespaceWarned || collidingMemberIds(catalog).length === 0)
|
|
63
|
+
return;
|
|
64
|
+
console.warn('bifrost: upstream provider "chain" collides with the chain display namespace; its models are skipped');
|
|
65
|
+
chainNamespaceWarned = true;
|
|
66
|
+
}
|
|
58
67
|
/**
|
|
59
68
|
* Compose the gateway model list from live Bifrost state (the ported
|
|
60
69
|
* `discoverModels` with sigrun's failure semantics — throws instead of
|
|
@@ -85,6 +94,7 @@ export async function discoverModels(token, baseUrl, options, signal) {
|
|
|
85
94
|
throw new Error("provider-bifrost: credential does not match any virtual key in /api/governance/virtual-keys");
|
|
86
95
|
}
|
|
87
96
|
const { catalog, rules } = await wireDiscovery(managementToken, token, baseUrl, scopeId, signal);
|
|
97
|
+
warnOnChainNamespaceCollision(catalog);
|
|
88
98
|
const chains = wireRulesToChains(rules);
|
|
89
99
|
if (chains.length === 0) {
|
|
90
100
|
throw new Error(`provider-bifrost: no chain routing rules found for ${scopeId} — is chains-first routing configured for this VK?`);
|
|
@@ -100,6 +110,7 @@ export async function discoverModels(token, baseUrl, options, signal) {
|
|
|
100
110
|
throw new Error("provider-bifrost: publishChains and publishUpstream are both false — nothing to publish");
|
|
101
111
|
}
|
|
102
112
|
const catalog = await wireCatalog(token, baseUrl, signal);
|
|
113
|
+
warnOnChainNamespaceCollision(catalog);
|
|
103
114
|
const models = buildAllModels([], catalog, PROVIDER_ID, baseUrl, false, true, options.thinkingOverrides, options.nativeProtocols);
|
|
104
115
|
if (models.length === 0) {
|
|
105
116
|
throw new Error("provider-bifrost: /v1/models returned no usable entries; refusing to publish an empty catalog");
|
|
@@ -124,6 +135,7 @@ export function createBifrostPlugin(options) {
|
|
|
124
135
|
// The ambient base URL, captured at credential resolution (the only
|
|
125
136
|
// place the env seam is available); the baked settings URL outranks it.
|
|
126
137
|
let ambientBaseUrl;
|
|
138
|
+
const wireNames = new Map();
|
|
127
139
|
function status() {
|
|
128
140
|
if (!credentialResolved)
|
|
129
141
|
return "not_configured";
|
|
@@ -199,9 +211,9 @@ export function createBifrostPlugin(options) {
|
|
|
199
211
|
// default). The map always registers all three: the registry entry
|
|
200
212
|
// is inert for models that never publish that api.
|
|
201
213
|
api: {
|
|
202
|
-
"openai-completions": openAICompletionsApi(),
|
|
203
|
-
"openai-responses": openAIResponsesApi(),
|
|
204
|
-
"anthropic-messages": anthropicMessagesApi(),
|
|
214
|
+
"openai-completions": withWireNames(openAICompletionsApi(), wireNames),
|
|
215
|
+
"openai-responses": withWireNames(openAIResponsesApi(), wireNames),
|
|
216
|
+
"anthropic-messages": withWireNames(anthropicMessagesApi(), wireNames),
|
|
205
217
|
},
|
|
206
218
|
fetchModels: async (context) => {
|
|
207
219
|
const key = context.credential?.type === "api_key"
|
|
@@ -214,6 +226,11 @@ export function createBifrostPlugin(options) {
|
|
|
214
226
|
const url = options.baseUrl ?? ambientBaseUrl;
|
|
215
227
|
try {
|
|
216
228
|
const models = await discoverModels(key, url, options, context.signal);
|
|
229
|
+
wireNames.clear();
|
|
230
|
+
for (const model of models) {
|
|
231
|
+
if (model.id.startsWith(CHAIN_ID_PREFIX))
|
|
232
|
+
wireNames.set(model.id, model.name);
|
|
233
|
+
}
|
|
217
234
|
noteRefresh(true);
|
|
218
235
|
return models;
|
|
219
236
|
}
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Api, AssistantMessageEventStream, Model, ProviderStreams } from "@earendil-works/pi-ai";
|
|
2
|
+
/** Resolve the gateway's model name from a sigrun-side display id. */
|
|
3
|
+
export declare function chainWireName(id: string, known: ReadonlyMap<string, string>): string;
|
|
4
|
+
/**
|
|
5
|
+
* Restore the sigrun-side display id on a reply.
|
|
6
|
+
*
|
|
7
|
+
* The gateway names its reply after the model it was asked for — the bare
|
|
8
|
+
* wire name (`acme-flash`) — and pi-ai copies that name onto the reply's
|
|
9
|
+
* messages, so a persisted message from a chain model would forget where it
|
|
10
|
+
* came from. Every event is forwarded, the terminal `done`/`error` included,
|
|
11
|
+
* because pi-ai derives `result()` (and `durationMs`) from that event.
|
|
12
|
+
*
|
|
13
|
+
* `model` is the request's model; it is optional only because the display id
|
|
14
|
+
* and wire name already describe the reply. Supply it so a stream that breaks
|
|
15
|
+
* outright can report an accurate `api`/`provider`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function withDisplayId(stream: AssistantMessageEventStream, displayId: string, wireName: string, model?: Model<Api>): AssistantMessageEventStream;
|
|
18
|
+
/** Keep display ids at the provider boundary and bare names on the gateway wire. */
|
|
19
|
+
export declare function withWireNames(streams: ProviderStreams, known: ReadonlyMap<string, string>): ProviderStreams;
|
package/dist/wire.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
2
|
+
import { CHAIN_ID_PREFIX, PROVIDER_ID } from "./catalog.js";
|
|
3
|
+
/** Resolve the gateway's model name from a sigrun-side display id. */
|
|
4
|
+
export function chainWireName(id, known) {
|
|
5
|
+
const discovered = known.get(id);
|
|
6
|
+
if (discovered !== undefined)
|
|
7
|
+
return discovered;
|
|
8
|
+
return id.startsWith(CHAIN_ID_PREFIX) ? id.slice(CHAIN_ID_PREFIX.length) : id;
|
|
9
|
+
}
|
|
10
|
+
function wireModel(model, known) {
|
|
11
|
+
return { ...model, id: chainWireName(model.id, known) };
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fold this model's display id back to the wire name on outgoing history.
|
|
15
|
+
*
|
|
16
|
+
* pi-ai replays an assistant turn as "the same model" only when its provider,
|
|
17
|
+
* api and model all match the request (`transformMessages`), and that gate is
|
|
18
|
+
* what keeps thinking blocks with their signatures, tool-call thought
|
|
19
|
+
* signatures, and tool-call ids. The inner implementation replays against the
|
|
20
|
+
* WIRE model, so a persisted `chain/` reply would otherwise read as a foreign
|
|
21
|
+
* model's turn and lose every one of those. Only this provider's own turns for
|
|
22
|
+
* this exact model are renamed: another model's (or another provider's)
|
|
23
|
+
* history keeps its cross-model semantics.
|
|
24
|
+
*/
|
|
25
|
+
function replayContext(context, model, wireName) {
|
|
26
|
+
let changed = false;
|
|
27
|
+
const messages = context.messages.map((message) => {
|
|
28
|
+
if (message.role !== "assistant")
|
|
29
|
+
return message;
|
|
30
|
+
if (message.provider !== model.provider || message.model !== model.id)
|
|
31
|
+
return message;
|
|
32
|
+
changed = true;
|
|
33
|
+
return { ...message, model: wireName };
|
|
34
|
+
});
|
|
35
|
+
// Untouched histories cross by reference, so a member request costs nothing.
|
|
36
|
+
return changed ? { ...context, messages } : context;
|
|
37
|
+
}
|
|
38
|
+
/** The message an event carries, whichever field its variant uses. */
|
|
39
|
+
function eventMessage(event) {
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case "done":
|
|
42
|
+
return event.message;
|
|
43
|
+
case "error":
|
|
44
|
+
return event.error;
|
|
45
|
+
default:
|
|
46
|
+
return event.partial;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The message a stream that broke outright reports.
|
|
51
|
+
*
|
|
52
|
+
* Mirrors pi-ai's own setup-error message (`api/lazy.js`): an empty assistant
|
|
53
|
+
* reply whose `stopReason` is `"error"` and whose text names the cause. pi-ai
|
|
54
|
+
* never replays an errored assistant turn, so this message only carries the
|
|
55
|
+
* failure to the consumer — but it MUST exist, because `EventStream.end()`
|
|
56
|
+
* without a result leaves `result()` unresolved forever.
|
|
57
|
+
*/
|
|
58
|
+
function failureMessage(cause, displayId, model) {
|
|
59
|
+
return {
|
|
60
|
+
role: "assistant",
|
|
61
|
+
content: [],
|
|
62
|
+
api: model?.api ?? "unknown",
|
|
63
|
+
provider: model?.provider ?? PROVIDER_ID,
|
|
64
|
+
model: displayId,
|
|
65
|
+
usage: {
|
|
66
|
+
input: 0,
|
|
67
|
+
output: 0,
|
|
68
|
+
cacheRead: 0,
|
|
69
|
+
cacheWrite: 0,
|
|
70
|
+
totalTokens: 0,
|
|
71
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
72
|
+
},
|
|
73
|
+
stopReason: "error",
|
|
74
|
+
errorMessage: cause instanceof Error ? cause.message : String(cause),
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Restore the sigrun-side display id on a reply.
|
|
80
|
+
*
|
|
81
|
+
* The gateway names its reply after the model it was asked for — the bare
|
|
82
|
+
* wire name (`acme-flash`) — and pi-ai copies that name onto the reply's
|
|
83
|
+
* messages, so a persisted message from a chain model would forget where it
|
|
84
|
+
* came from. Every event is forwarded, the terminal `done`/`error` included,
|
|
85
|
+
* because pi-ai derives `result()` (and `durationMs`) from that event.
|
|
86
|
+
*
|
|
87
|
+
* `model` is the request's model; it is optional only because the display id
|
|
88
|
+
* and wire name already describe the reply. Supply it so a stream that breaks
|
|
89
|
+
* outright can report an accurate `api`/`provider`.
|
|
90
|
+
*/
|
|
91
|
+
export function withDisplayId(stream, displayId, wireName, model) {
|
|
92
|
+
const restored = createAssistantMessageEventStream();
|
|
93
|
+
void (async () => {
|
|
94
|
+
try {
|
|
95
|
+
let result;
|
|
96
|
+
for await (const event of stream) {
|
|
97
|
+
const message = eventMessage(event);
|
|
98
|
+
if (message.model === wireName)
|
|
99
|
+
message.model = displayId;
|
|
100
|
+
// pi-ai resolves result() from the terminal event, so forward it
|
|
101
|
+
// untouched (the id is already restored) and end() after it.
|
|
102
|
+
if (event.type === "done")
|
|
103
|
+
result = event.message;
|
|
104
|
+
else if (event.type === "error")
|
|
105
|
+
result = event.error;
|
|
106
|
+
restored.push(event);
|
|
107
|
+
}
|
|
108
|
+
// A stream can settle through end(result) without any event; pi-ai's
|
|
109
|
+
// own forwarder reads that result so result() never hangs.
|
|
110
|
+
if (result === undefined)
|
|
111
|
+
result = await stream.result();
|
|
112
|
+
if (result !== undefined && result.model === wireName)
|
|
113
|
+
result.model = displayId;
|
|
114
|
+
restored.end(result);
|
|
115
|
+
}
|
|
116
|
+
catch (cause) {
|
|
117
|
+
// pi-ai streams terminate with an error event and their iterator does
|
|
118
|
+
// not throw, so this is an upstream contract violation. Report it the
|
|
119
|
+
// way pi-ai reports a failed setup — a terminal error event — and
|
|
120
|
+
// settle with that message: ending without a result would leave every
|
|
121
|
+
// `await stream.result()` waiting forever, and the cause would be lost.
|
|
122
|
+
const failure = failureMessage(cause, displayId, model);
|
|
123
|
+
restored.push({ type: "error", reason: "error", error: failure });
|
|
124
|
+
restored.end(failure);
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
return restored;
|
|
128
|
+
}
|
|
129
|
+
/** Keep display ids at the provider boundary and bare names on the gateway wire. */
|
|
130
|
+
export function withWireNames(streams, known) {
|
|
131
|
+
/** Replies are named after the request; a stripped id must come back whole. */
|
|
132
|
+
const restoreReply = (model, wireName, stream) => (wireName === model.id ? stream : withDisplayId(stream, model.id, wireName, model));
|
|
133
|
+
/**
|
|
134
|
+
* Cross the boundary once: the request names the wire model, the history
|
|
135
|
+
* replays under that same name, and the reply keeps the display id.
|
|
136
|
+
*/
|
|
137
|
+
const delegate = (model, context, call) => {
|
|
138
|
+
const wireName = chainWireName(model.id, known);
|
|
139
|
+
const history = wireName === model.id ? context : replayContext(context, model, wireName);
|
|
140
|
+
return restoreReply(model, wireName, call(wireModel(model, known), history));
|
|
141
|
+
};
|
|
142
|
+
const wrapped = {
|
|
143
|
+
stream: (model, context, options) => delegate(model, context, (wire, history) => streams.stream(wire, history, options)),
|
|
144
|
+
streamSimple: (model, context, options) => delegate(model, context, (wire, history) => streams.streamSimple(wire, history, options)),
|
|
145
|
+
};
|
|
146
|
+
if (streams.fetchDeferred !== undefined) {
|
|
147
|
+
const fetchDeferred = streams.fetchDeferred;
|
|
148
|
+
// A deferred fetch replays no history: only the request and reply cross.
|
|
149
|
+
wrapped.fetchDeferred = (model, handle, options) => {
|
|
150
|
+
const wireName = chainWireName(model.id, known);
|
|
151
|
+
return restoreReply(model, wireName, fetchDeferred.call(streams, wireModel(model, known), handle, options));
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (streams.cancelDeferred !== undefined) {
|
|
155
|
+
const cancelDeferred = streams.cancelDeferred;
|
|
156
|
+
wrapped.cancelDeferred = (model, handle, options) => cancelDeferred.call(streams, wireModel(model, known), handle, options);
|
|
157
|
+
}
|
|
158
|
+
return wrapped;
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigrun/provider-bifrost",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Sigrun provider plugin: a Bifrost LLM gateway as one dynamic provider
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Sigrun provider plugin: a Bifrost LLM gateway as one dynamic provider — chain models from VK routing rules and/or the upstream catalogue, with optional per-family native protocol surfaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22.19.0"
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"prepare": "npm run build"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@earendil-works/pi-ai": "1.
|
|
29
|
-
"@earendil-works/pi-durable": "1.
|
|
28
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
29
|
+
"@earendil-works/pi-durable": "1.4.0",
|
|
30
30
|
"typebox": "1.3.27",
|
|
31
31
|
"@types/node": "^26.6.4",
|
|
32
32
|
"typescript": "^7.0.2",
|