@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 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 { CHAIN_ID_PREFIX } from "./catalog.js";
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) => streams.stream(wireModel(model, known), context, options),
16
- streamSimple: (model, context, options) => streams.streamSimple(wireModel(model, known), 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
- wrapped.fetchDeferred = (model, handle, options) => fetchDeferred.call(streams, wireModel(model, known), handle, options);
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.0",
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": {