@sigrun/provider-bifrost 0.2.0 → 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 +8 -0
- package/dist/wire.d.ts +15 -1
- package/dist/wire.js +136 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
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
|
+
|
|
15
23
|
## [0.2.0] - 2026-10-30
|
|
16
24
|
### Changed
|
|
17
25
|
- **Breaking:** chain model references change from `bifrost/bifrost/<chain>`
|
package/dist/wire.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
import type { ProviderStreams } from "@earendil-works/pi-ai";
|
|
1
|
+
import type { Api, AssistantMessageEventStream, Model, ProviderStreams } from "@earendil-works/pi-ai";
|
|
2
2
|
/** Resolve the gateway's model name from a sigrun-side display id. */
|
|
3
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;
|
|
4
18
|
/** Keep display ids at the provider boundary and bare names on the gateway wire. */
|
|
5
19
|
export declare function withWireNames(streams: ProviderStreams, known: ReadonlyMap<string, string>): ProviderStreams;
|
package/dist/wire.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
2
|
+
import { CHAIN_ID_PREFIX, PROVIDER_ID } from "./catalog.js";
|
|
2
3
|
/** Resolve the gateway's model name from a sigrun-side display id. */
|
|
3
4
|
export function chainWireName(id, known) {
|
|
4
5
|
const discovered = known.get(id);
|
|
@@ -9,15 +10,146 @@ export function chainWireName(id, known) {
|
|
|
9
10
|
function wireModel(model, known) {
|
|
10
11
|
return { ...model, id: chainWireName(model.id, known) };
|
|
11
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
|
+
}
|
|
12
129
|
/** Keep display ids at the provider boundary and bare names on the gateway wire. */
|
|
13
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
|
+
};
|
|
14
142
|
const wrapped = {
|
|
15
|
-
stream: (model, context, options) =>
|
|
16
|
-
streamSimple: (model, context, options) =>
|
|
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)),
|
|
17
145
|
};
|
|
18
146
|
if (streams.fetchDeferred !== undefined) {
|
|
19
147
|
const fetchDeferred = streams.fetchDeferred;
|
|
20
|
-
|
|
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
|
+
};
|
|
21
153
|
}
|
|
22
154
|
if (streams.cancelDeferred !== undefined) {
|
|
23
155
|
const cancelDeferred = streams.cancelDeferred;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigrun/provider-bifrost",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
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": {
|