@msm-core/mini 0.8.0 → 0.14.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.
@@ -8,11 +8,15 @@
8
8
  * TTL: timeoutMs + 30s safety margin
9
9
  */
10
10
  import type { RedisLike } from "./redis-types.js";
11
- export interface LockHandle {
12
- release(): Promise<void>;
13
- extend(ttlMs: number): Promise<boolean>;
14
- }
15
- export declare class RedisDistributedLock {
11
+ import type { LockHandle, RunLockPort } from "../core/types.js";
12
+ /**
13
+ * Re-exported from its new home in `core/types.ts` (س٦), where it belongs with
14
+ * the port every lock implements rather than with one implementation of it.
15
+ * Every existing `import { LockHandle } from "@msm-core/mini/adapters"` keeps
16
+ * working, unchanged.
17
+ */
18
+ export type { LockHandle };
19
+ export declare class RedisDistributedLock implements RunLockPort {
16
20
  private readonly redis;
17
21
  private readonly prefix;
18
22
  constructor(redis: RedisLike, prefix?: string);
@@ -2,6 +2,7 @@
2
2
  * Anthropic Brain — Claude via Messages API.
3
3
  * Peer dependency: @anthropic-ai/sdk >= 0.20.0
4
4
  */
5
+ import { respondingModel } from "../core/types.js";
5
6
  import { computeCostUsd } from "./pricing.js";
6
7
  import { withRetry } from "./retry.js";
7
8
  import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
@@ -63,7 +64,7 @@ export function createAnthropicBrain(opts) {
63
64
  // land in `AnthropicMessage` — the fields this brain reads — so the
64
65
  // payload is built once, from one shape.
65
66
  const response = input.onChunk
66
- ? await withRetry(() => streamMessage(client, params, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
67
+ ? await withRetry((emitOnce) => streamMessage(client, params, emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {})
67
68
  : await withRetry(() => client.messages.create(params, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
68
69
  const content = response.content;
69
70
  const inputTokens = response.usage.input_tokens;
@@ -87,6 +88,7 @@ export function createAnthropicBrain(opts) {
87
88
  orchestration,
88
89
  usage: { inputTokens, outputTokens },
89
90
  costUsd,
91
+ ...respondingModel(response),
90
92
  };
91
93
  }
92
94
  // Text
@@ -97,6 +99,11 @@ export function createAnthropicBrain(opts) {
97
99
  orchestration: { action: "respond", confidence: 0.95 },
98
100
  usage: { inputTokens, outputTokens },
99
101
  costUsd,
102
+ // Who answered, off the message itself — Claude names the model that
103
+ // served the request, which is not always the alias that was asked for.
104
+ // Streamed or not, `response` is the same shape: `message_start`
105
+ // carries the name and `accumulateAnthropic` keeps it.
106
+ ...respondingModel(response),
100
107
  };
101
108
  },
102
109
  };
@@ -2,6 +2,7 @@
2
2
  * Gemini Brain — Google Gemini 2.5 Flash (primary brain for UTS).
3
3
  * Peer dependency: @google/generative-ai >= 0.14.0
4
4
  */
5
+ import { respondingModel } from "../core/types.js";
5
6
  import { withRetry } from "./retry.js";
6
7
  import { foldToolResults, toWireMessage, useToolOrchestration, } from "./tool-context.js";
7
8
  import { accumulateGemini, consumeStream, geminiDelta, } from "./streaming.js";
@@ -68,7 +69,7 @@ export function createGeminiBrain(opts) {
68
69
  // land in `GeminiResponse` — the fields this brain reads — so the payload
69
70
  // is built once, from one shape.
70
71
  const response = input.onChunk
71
- ? await withRetry(() => streamContent(() => geminiModel.generateContentStream(request, input.signal ? { signal: input.signal } : {}), input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
72
+ ? await withRetry((emitOnce) => streamContent(() => geminiModel.generateContentStream(request, input.signal ? { signal: input.signal } : {}), emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {})
72
73
  : (await withRetry(() => geminiModel.generateContent(request, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {})).response;
73
74
  const parts = response?.candidates?.[0]?.content?.parts ?? [];
74
75
  // Token usage + cost — computed ONCE and returned on BOTH the tool-call and text
@@ -100,7 +101,12 @@ export function createGeminiBrain(opts) {
100
101
  }
101
102
  const orchestration = useToolOrchestration(calls, 0.9);
102
103
  if (orchestration) {
103
- return { orchestration, costUsd, ...(usage ? { usage } : {}) };
104
+ return {
105
+ orchestration,
106
+ costUsd,
107
+ ...(usage ? { usage } : {}),
108
+ ...respondingModel(response),
109
+ };
104
110
  }
105
111
  // Text response
106
112
  const textPart = parts.find((p) => "text" in p && typeof p.text === "string");
@@ -110,6 +116,13 @@ export function createGeminiBrain(opts) {
110
116
  orchestration: { action: "respond", confidence: 0.95 },
111
117
  costUsd,
112
118
  ...(usage ? { usage } : {}),
119
+ // Who answered — Gemini spells it `modelVersion`, and it is the field
120
+ // that tells `gemini-2.5-flash` from the dated build that served it.
121
+ // The SDK's non-streaming path hands back the parsed response body as
122
+ // it arrived, so the field survives even though its own `.d.ts` stops
123
+ // at three properties; on the streamed path every element repeats it
124
+ // and `accumulateGemini` carries it through. One line, both paths.
125
+ ...respondingModel(response),
113
126
  };
114
127
  },
115
128
  };
@@ -2,6 +2,7 @@
2
2
  * Ollama Brain — local model via Ollama REST API.
3
3
  * No peer dependencies — plain HTTP fetch.
4
4
  */
5
+ import { respondingModel } from "../core/types.js";
5
6
  import { withRetry } from "./retry.js";
6
7
  import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
7
8
  import { accumulateOllama, consumeNdjson, } from "./streaming.js";
@@ -60,7 +61,7 @@ export function createOllamaBrain(opts) {
60
61
  });
61
62
  let data;
62
63
  if (input.onChunk) {
63
- data = await withRetry(() => streamChat(post, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {});
64
+ data = await withRetry((emitOnce) => streamChat(post, emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {});
64
65
  }
65
66
  else {
66
67
  // Verbatim: the fetch is retried, the status check and the body read
@@ -88,13 +89,18 @@ export function createOllamaBrain(opts) {
88
89
  if (calls[0]?.name) {
89
90
  const orchestration = useToolOrchestration(calls, 0.9);
90
91
  if (orchestration)
91
- return { orchestration, ...usageBlock };
92
+ return { orchestration, ...usageBlock, ...respondingModel(data) };
92
93
  }
93
94
  const text = data.message?.content ?? "";
94
95
  return {
95
96
  generation: { response_text: text },
96
97
  orchestration: { action: "respond", confidence: 0.85 },
97
98
  ...usageBlock,
99
+ // Who answered — `/api/chat` echoes the served model at the top level,
100
+ // and on a local host that is the one place a swapped or re-tagged
101
+ // model shows itself. Streamed too: every NDJSON line repeats it and
102
+ // `accumulateOllama` keeps the last.
103
+ ...respondingModel(data),
98
104
  };
99
105
  },
100
106
  };
@@ -2,6 +2,7 @@
2
2
  * OpenAI Brain — wraps the OpenAI Chat Completions API.
3
3
  * Peer dependency: openai >= 4.0.0
4
4
  */
5
+ import { respondingModel } from "../core/types.js";
5
6
  import { computeCostUsd } from "./pricing.js";
6
7
  import { withRetry } from "./retry.js";
7
8
  import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
@@ -56,7 +57,7 @@ export function createOpenAIBrain(opts) {
56
57
  // construction (`OpenAICompletion`), so there is one payload builder,
57
58
  // not two that must be kept in step.
58
59
  const response = input.onChunk
59
- ? await withRetry(() => streamCompletion(client, body, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
60
+ ? await withRetry((emitOnce) => streamCompletion(client, body, emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {})
60
61
  : await withRetry(() => client.chat.completions.create(body, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
61
62
  const choice = response.choices[0];
62
63
  if (!choice)
@@ -93,13 +94,19 @@ export function createOpenAIBrain(opts) {
93
94
  });
94
95
  const orchestration = useToolOrchestration(calls, 0.9);
95
96
  if (orchestration)
96
- return { orchestration, ...usageBlock };
97
+ return { orchestration, ...usageBlock, ...respondingModel(response) };
97
98
  }
98
99
  const text = msg.content ?? "";
99
100
  return {
100
101
  generation: { response_text: text },
101
102
  orchestration: { action: "respond", confidence: 0.95 },
102
103
  ...usageBlock,
104
+ // Who answered, off the completion itself — `gpt-4o-mini` asked for can
105
+ // come back as `gpt-4o-mini-2024-07-18`, and an override can make it
106
+ // something else entirely. `response` is the SAME shape either way, so
107
+ // this one line covers the streamed path too: the chunks carry `model`
108
+ // and `accumulateOpenAI` folds it in.
109
+ ...respondingModel(response),
103
110
  };
104
111
  },
105
112
  };
@@ -5,7 +5,96 @@
5
5
  * run with an error outcome. Every built-in brain now wraps its provider call
6
6
  * in withRetry(): a single bounded retry on transient failures, abort-aware
7
7
  * (never retries a cancelled call — that's the loop's time budget firing).
8
+ *
9
+ * ── Retrying a STREAM is not retrying a call (ب١/٢) ──────────────────────────
10
+ *
11
+ * A retried non-streaming call is invisible: the first attempt's response is
12
+ * thrown away and only the second one is ever seen. A retried STREAMING call is
13
+ * not, because the first attempt already spoke. It pushed "The contract sta"
14
+ * into the consumer's `onChunk` and then died on a 429; the second attempt
15
+ * opens a fresh stream and says the whole sentence again, and what the human
16
+ * watching reads is "The contract staThe contract states…". The payload was
17
+ * always right — only the display lied — which is exactly why it survived ب١.
18
+ *
19
+ * The cure has to live at the retry boundary, because that is the only place
20
+ * that knows an earlier attempt existed. `emitOnce` below is that boundary made
21
+ * available to the caller: wrap a sink in it and the run's chunks are delivered
22
+ * by AT MOST ONE attempt.
23
+ *
24
+ * **The invariant this buys.** What reaches the consumer is always a PREFIX of
25
+ * the payload the call finally returns. Never a different string, never a
26
+ * longer one. That is the whole property: a display may lag the truth, it may
27
+ * stop short of it, but it may never contradict it — and "The contract staThe
28
+ * contract states…" contradicts it, because no payload ever said that.
29
+ *
30
+ * **Gated on delivery, not on attempt number.** "Mute every attempt after the
31
+ * first" is the obvious rule and it is wrong: a first attempt that dies while
32
+ * opening the socket has shown the reader nothing, and muting the retry would
33
+ * turn a recoverable stumble into a run that streams not one character. So the
34
+ * gate closes only once a chunk has actually been DELIVERED, and a silent
35
+ * failure costs the reader nothing.
36
+ *
37
+ * **The decision is taken per attempt, before the call.** An attempt either
38
+ * speaks or it does not; a gate re-read mid-flight could fall silent halfway
39
+ * through a sentence, which is a worse artifact than the duplicate it replaced.
40
+ *
41
+ * **What this costs, stated plainly.** When the first attempt dies mid-sentence
42
+ * the reader's display stops there: the retry's text is suppressed, and the rest
43
+ * of the answer arrives with the payload rather than character by character. It
44
+ * is a truncated display, not a wrong one — and `onChunk` is display, not truth
45
+ * (ب١), so the returned payload is complete either way.
46
+ *
47
+ * **The alternative that was rejected.** Suppressing only the already-delivered
48
+ * PREFIX and streaming the remainder would keep the display live — but only if
49
+ * the retry repeats itself word for word, and a re-issued model call is under no
50
+ * obligation to. When it diverges, prefix-skipping splices the head of one
51
+ * answer onto the tail of another and hands the reader a sentence neither
52
+ * attempt produced. Stopping is honest; splicing is not. Delivering the retry
53
+ * losslessly needs a "discard what I showed you" signal on `BrainChunk`, which
54
+ * is a consumer-visible contract change and not this file's to make.
55
+ *
56
+ * ── The signal, and the covenant it must not break (ص٢/٢) ───────────────────
57
+ *
58
+ * That signal now exists: `BrainChunk.reset`. So the gate has TWO patterns, and
59
+ * which one a run gets is decided by the consumer, never by this file.
60
+ *
61
+ * • **prefix** — the default, and the ص١ path unchanged. A retried attempt is
62
+ * muted; the display stops where the dead attempt stopped; what reached the
63
+ * reader is a prefix of the payload.
64
+ *
65
+ * • **reset** — only for a sink that went through `acceptResets`. The retried
66
+ * attempt is NOT muted. Its first delivered chunk carries `reset: true`,
67
+ * meaning "throw away what I sent, start here", and the rest stream
68
+ * normally. The reader ends up holding the whole payload: nothing lost,
69
+ * nothing doubled.
70
+ *
71
+ * **A consumer that did not declare is not affected by any of this.** It is not
72
+ * a softer default — it is the same branch, the same `SILENT`, the same bytes,
73
+ * and the reset branch is unreachable without the declaration. That matters
74
+ * because delivering `reset` to a consumer that appends and ignores it would
75
+ * reproduce ب١/٢'s duplicate display exactly.
76
+ *
77
+ * **The reset rides on a real chunk rather than arriving as its own empty one.**
78
+ * Two reasons. It cannot be split — a reader that acts on one message and drops
79
+ * the next can never clear without also receiving the text that replaces it.
80
+ * And every chunk still corresponds to model text, so "concatenate, clearing at
81
+ * a reset" yields the payload with no zero-length special case. It is also LAZY:
82
+ * built when the retried attempt actually delivers, so an attempt that dies
83
+ * silently a second time clears nothing and leaves the display where ص١ would
84
+ * have left it.
85
+ */
86
+ /**
87
+ * Wrap a side-effecting sink so that only the attempt that first delivers
88
+ * through it may deliver at all — unless the sink declared `acceptResets`, in
89
+ * which case a later attempt may deliver after telling it to clear (ص٢/٢).
90
+ *
91
+ * Handed to the retried function; ignoring it is the pre-ب١/٢ behaviour, so no
92
+ * caller is broken by its existence. The signature stays generic on purpose:
93
+ * every call site today passes a chunk sink, but narrowing the parameter to
94
+ * `BrainChunk` would be a surface change for no gain — the reset branch reaches
95
+ * the chunk type through `isResetAware`, which is a runtime fact, not a guess.
8
96
  */
97
+ export type SinkGate = <A extends unknown[]>(sink: (...args: A) => void) => (...args: A) => void;
9
98
  export interface RetryOpts {
10
99
  /** Total attempts including the first (default: 2 = one retry). */
11
100
  attempts?: number;
@@ -16,5 +105,12 @@ export interface RetryOpts {
16
105
  }
17
106
  /** True for errors worth one retry: 429, 5xx, or low-level network failures. */
18
107
  export declare function isTransientError(err: unknown): boolean;
19
- /** Run `fn`, retrying once (by default) on transient provider errors. */
20
- export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOpts): Promise<T>;
108
+ /**
109
+ * Run `fn`, retrying once (by default) on transient provider errors.
110
+ *
111
+ * `fn` receives an `emitOnce` gate (see the header): wrap the consumer's chunk
112
+ * sink in it and no text is delivered twice across attempts. A zero-argument
113
+ * thunk — every caller written before ب١/٢ — is assignable unchanged and behaves
114
+ * exactly as it did.
115
+ */
116
+ export declare function withRetry<T>(fn: (emitOnce: SinkGate) => Promise<T>, opts?: RetryOpts): Promise<T>;
@@ -5,7 +5,105 @@
5
5
  * run with an error outcome. Every built-in brain now wraps its provider call
6
6
  * in withRetry(): a single bounded retry on transient failures, abort-aware
7
7
  * (never retries a cancelled call — that's the loop's time budget firing).
8
+ *
9
+ * ── Retrying a STREAM is not retrying a call (ب١/٢) ──────────────────────────
10
+ *
11
+ * A retried non-streaming call is invisible: the first attempt's response is
12
+ * thrown away and only the second one is ever seen. A retried STREAMING call is
13
+ * not, because the first attempt already spoke. It pushed "The contract sta"
14
+ * into the consumer's `onChunk` and then died on a 429; the second attempt
15
+ * opens a fresh stream and says the whole sentence again, and what the human
16
+ * watching reads is "The contract staThe contract states…". The payload was
17
+ * always right — only the display lied — which is exactly why it survived ب١.
18
+ *
19
+ * The cure has to live at the retry boundary, because that is the only place
20
+ * that knows an earlier attempt existed. `emitOnce` below is that boundary made
21
+ * available to the caller: wrap a sink in it and the run's chunks are delivered
22
+ * by AT MOST ONE attempt.
23
+ *
24
+ * **The invariant this buys.** What reaches the consumer is always a PREFIX of
25
+ * the payload the call finally returns. Never a different string, never a
26
+ * longer one. That is the whole property: a display may lag the truth, it may
27
+ * stop short of it, but it may never contradict it — and "The contract staThe
28
+ * contract states…" contradicts it, because no payload ever said that.
29
+ *
30
+ * **Gated on delivery, not on attempt number.** "Mute every attempt after the
31
+ * first" is the obvious rule and it is wrong: a first attempt that dies while
32
+ * opening the socket has shown the reader nothing, and muting the retry would
33
+ * turn a recoverable stumble into a run that streams not one character. So the
34
+ * gate closes only once a chunk has actually been DELIVERED, and a silent
35
+ * failure costs the reader nothing.
36
+ *
37
+ * **The decision is taken per attempt, before the call.** An attempt either
38
+ * speaks or it does not; a gate re-read mid-flight could fall silent halfway
39
+ * through a sentence, which is a worse artifact than the duplicate it replaced.
40
+ *
41
+ * **What this costs, stated plainly.** When the first attempt dies mid-sentence
42
+ * the reader's display stops there: the retry's text is suppressed, and the rest
43
+ * of the answer arrives with the payload rather than character by character. It
44
+ * is a truncated display, not a wrong one — and `onChunk` is display, not truth
45
+ * (ب١), so the returned payload is complete either way.
46
+ *
47
+ * **The alternative that was rejected.** Suppressing only the already-delivered
48
+ * PREFIX and streaming the remainder would keep the display live — but only if
49
+ * the retry repeats itself word for word, and a re-issued model call is under no
50
+ * obligation to. When it diverges, prefix-skipping splices the head of one
51
+ * answer onto the tail of another and hands the reader a sentence neither
52
+ * attempt produced. Stopping is honest; splicing is not. Delivering the retry
53
+ * losslessly needs a "discard what I showed you" signal on `BrainChunk`, which
54
+ * is a consumer-visible contract change and not this file's to make.
55
+ *
56
+ * ── The signal, and the covenant it must not break (ص٢/٢) ───────────────────
57
+ *
58
+ * That signal now exists: `BrainChunk.reset`. So the gate has TWO patterns, and
59
+ * which one a run gets is decided by the consumer, never by this file.
60
+ *
61
+ * • **prefix** — the default, and the ص١ path unchanged. A retried attempt is
62
+ * muted; the display stops where the dead attempt stopped; what reached the
63
+ * reader is a prefix of the payload.
64
+ *
65
+ * • **reset** — only for a sink that went through `acceptResets`. The retried
66
+ * attempt is NOT muted. Its first delivered chunk carries `reset: true`,
67
+ * meaning "throw away what I sent, start here", and the rest stream
68
+ * normally. The reader ends up holding the whole payload: nothing lost,
69
+ * nothing doubled.
70
+ *
71
+ * **A consumer that did not declare is not affected by any of this.** It is not
72
+ * a softer default — it is the same branch, the same `SILENT`, the same bytes,
73
+ * and the reset branch is unreachable without the declaration. That matters
74
+ * because delivering `reset` to a consumer that appends and ignores it would
75
+ * reproduce ب١/٢'s duplicate display exactly.
76
+ *
77
+ * **The reset rides on a real chunk rather than arriving as its own empty one.**
78
+ * Two reasons. It cannot be split — a reader that acts on one message and drops
79
+ * the next can never clear without also receiving the text that replaces it.
80
+ * And every chunk still corresponds to model text, so "concatenate, clearing at
81
+ * a reset" yields the payload with no zero-length special case. It is also LAZY:
82
+ * built when the retried attempt actually delivers, so an attempt that dies
83
+ * silently a second time clears nothing and leaves the display where ص١ would
84
+ * have left it.
85
+ */
86
+ import { isResetAware } from "../core/types.js";
87
+ /** What a muted attempt streams into. */
88
+ const SILENT = () => { };
89
+ /**
90
+ * The reset pattern's sink for one retried attempt: the first thing it delivers
91
+ * says "start over", everything after it is ordinary.
92
+ *
93
+ * `cleared` is per-attempt, which is what makes a third attempt clear again —
94
+ * each retry replaces the display, it does not append to the one before it.
8
95
  */
96
+ function clearThenStream(sink) {
97
+ let cleared = false;
98
+ return (chunk) => {
99
+ if (cleared) {
100
+ sink(chunk);
101
+ return;
102
+ }
103
+ cleared = true;
104
+ sink({ ...chunk, reset: true });
105
+ };
106
+ }
9
107
  const TRANSIENT_CODES = new Set([
10
108
  "ECONNRESET",
11
109
  "ECONNREFUSED",
@@ -38,14 +136,46 @@ export function isTransientError(err) {
38
136
  function sleep(ms) {
39
137
  return new Promise((r) => setTimeout(r, ms));
40
138
  }
41
- /** Run `fn`, retrying once (by default) on transient provider errors. */
139
+ /**
140
+ * Run `fn`, retrying once (by default) on transient provider errors.
141
+ *
142
+ * `fn` receives an `emitOnce` gate (see the header): wrap the consumer's chunk
143
+ * sink in it and no text is delivered twice across attempts. A zero-argument
144
+ * thunk — every caller written before ب١/٢ — is assignable unchanged and behaves
145
+ * exactly as it did.
146
+ */
42
147
  export async function withRetry(fn, opts = {}) {
43
148
  const attempts = Math.max(1, opts.attempts ?? 2);
44
149
  const baseDelayMs = opts.baseDelayMs ?? 500;
150
+ /** Has any attempt so far actually pushed something through a gated sink? */
151
+ let delivered = false;
45
152
  let lastErr;
46
153
  for (let attempt = 1; attempt <= attempts; attempt++) {
154
+ // Read once, here, and not again inside the attempt — see the header.
155
+ const muted = delivered;
156
+ const emitOnce = muted
157
+ ? (sink) => {
158
+ // PREFIX pattern — the ص١ path, and the only one a sink that did not
159
+ // declare can reach.
160
+ if (!isResetAware(sink))
161
+ return SILENT;
162
+ // RESET pattern. The predicate is the evidence: `acceptResets` is the
163
+ // only producer of the mark and it accepts nothing but a chunk sink,
164
+ // so this narrowed value really does take a `BrainChunk`. The gate's
165
+ // own signature stays `(...args: A) => void` for its caller; the args
166
+ // are ignored because the sink is called with the chunk it is given.
167
+ const declared = sink;
168
+ const stream = clearThenStream(declared);
169
+ return (...args) => {
170
+ stream(args[0]);
171
+ };
172
+ }
173
+ : (sink) => (...args) => {
174
+ delivered = true;
175
+ sink(...args);
176
+ };
47
177
  try {
48
- return await fn();
178
+ return await fn(emitOnce);
49
179
  }
50
180
  catch (err) {
51
181
  lastErr = err;
@@ -23,6 +23,22 @@
23
23
  * a stream cannot reconstruct and no brain here consumes, so promising them
24
24
  * would be a lie the compiler would happily keep.
25
25
  *
26
+ * **The responder's name joined that set in س٦/٤**, by the same rule: the
27
+ * brains now read it (`respondingModel`) to record WHO answered in the session
28
+ * log, so it is a field this file has to carry or the answer is lost. And it is
29
+ * the one field here a stream CAN reconstruct — every provider stamps it on the
30
+ * wire, measured: OpenAI on each `ChatCompletionChunk`, Anthropic on the
31
+ * `message_start` message, Gemini as `modelVersion` on every stream element,
32
+ * Ollama on every NDJSON line. Leaving it out was not a design decision but the
33
+ * shape of the file before anyone needed it, and the cost was precise: nisus
34
+ * sets `hooks.onChunk` on every agent it builds, so EVERY turn it runs is a
35
+ * streamed turn, and every one of them would have logged no responder at all.
36
+ *
37
+ * **Absent stays absent.** Every carry below is a conditional spread: a stream
38
+ * that never names a model folds to the object it folded to yesterday, key for
39
+ * key, and the ب١ fixtures that assert `fold(chunks) === nonStreamed` keep
40
+ * passing untouched.
41
+ *
26
42
  * **Display, not truth (the governing limit of ب١).** Nothing in this file
27
43
  * writes a log event, touches a fingerprint, or decides anything. Chunks are
28
44
  * emitted for a human to look at; the payload the brain returns is the record.
@@ -67,6 +83,8 @@ export interface OpenAIStreamChunk {
67
83
  prompt_tokens?: number;
68
84
  completion_tokens?: number;
69
85
  } | null;
86
+ /** The serving model — OpenAI stamps it on every chunk (`ChatCompletionChunk.model`). */
87
+ model?: string;
70
88
  }
71
89
  /**
72
90
  * What the OpenAI brain reads off a completion — streamed or not.
@@ -89,6 +107,8 @@ export interface OpenAICompletion {
89
107
  prompt_tokens?: number;
90
108
  completion_tokens?: number;
91
109
  } | null;
110
+ /** Who answered — `ChatCompletion.model`, and the streamed chunks' `model`. */
111
+ model?: string;
92
112
  }
93
113
  /**
94
114
  * The text this chunk carries, or "" for a tool-call or usage-only chunk.
@@ -139,6 +159,8 @@ export interface AnthropicStreamEvent {
139
159
  input_tokens?: number;
140
160
  output_tokens?: number;
141
161
  };
162
+ /** The serving model — carried on the `message_start` event's message. */
163
+ model?: string;
142
164
  };
143
165
  content_block?: {
144
166
  type?: string;
@@ -162,6 +184,8 @@ export interface AnthropicMessage {
162
184
  input_tokens: number;
163
185
  output_tokens: number;
164
186
  };
187
+ /** Who answered — `Message.model`, and the `message_start` message's model. */
188
+ model?: string;
165
189
  }
166
190
  /**
167
191
  * The text this event carries, or "" for anything else (thinking included).
@@ -215,6 +239,16 @@ export interface GeminiResponse {
215
239
  promptTokenCount?: number;
216
240
  candidatesTokenCount?: number;
217
241
  };
242
+ /**
243
+ * Who answered. Google's spelling of the field, and the reason
244
+ * `respondingModel` knows two names for one thing.
245
+ *
246
+ * Undeclared by `@google/generative-ai`'s own `GenerateContentResponse` (it
247
+ * stops at three properties) — but the SDK hands back the parsed response
248
+ * body as it arrived, so the field is live on the wire and on the object.
249
+ * Declaring it here is what lets the brain read it without a cast.
250
+ */
251
+ modelVersion?: string;
218
252
  }
219
253
  /**
220
254
  * The text this stream item carries, across ALL of its text parts.
@@ -258,6 +292,8 @@ export interface OllamaStreamLine {
258
292
  };
259
293
  prompt_eval_count?: number;
260
294
  eval_count?: number;
295
+ /** The served tag — Ollama repeats it on every line. */
296
+ model?: string;
261
297
  }
262
298
  /** What the Ollama brain reads off `/api/chat` — streamed or not. */
263
299
  export interface OllamaChatResponse {
@@ -272,6 +308,12 @@ export interface OllamaChatResponse {
272
308
  };
273
309
  prompt_eval_count?: number;
274
310
  eval_count?: number;
311
+ /**
312
+ * Who answered — the served tag, top level in the `/api/chat` body. On a
313
+ * local host this is the one place a re-pulled or re-tagged model shows
314
+ * itself, since nothing about the request would change.
315
+ */
316
+ model?: string;
275
317
  }
276
318
  /**
277
319
  * The text this line carries.
@@ -23,6 +23,22 @@
23
23
  * a stream cannot reconstruct and no brain here consumes, so promising them
24
24
  * would be a lie the compiler would happily keep.
25
25
  *
26
+ * **The responder's name joined that set in س٦/٤**, by the same rule: the
27
+ * brains now read it (`respondingModel`) to record WHO answered in the session
28
+ * log, so it is a field this file has to carry or the answer is lost. And it is
29
+ * the one field here a stream CAN reconstruct — every provider stamps it on the
30
+ * wire, measured: OpenAI on each `ChatCompletionChunk`, Anthropic on the
31
+ * `message_start` message, Gemini as `modelVersion` on every stream element,
32
+ * Ollama on every NDJSON line. Leaving it out was not a design decision but the
33
+ * shape of the file before anyone needed it, and the cost was precise: nisus
34
+ * sets `hooks.onChunk` on every agent it builds, so EVERY turn it runs is a
35
+ * streamed turn, and every one of them would have logged no responder at all.
36
+ *
37
+ * **Absent stays absent.** Every carry below is a conditional spread: a stream
38
+ * that never names a model folds to the object it folded to yesterday, key for
39
+ * key, and the ب١ fixtures that assert `fold(chunks) === nonStreamed` keep
40
+ * passing untouched.
41
+ *
26
42
  * **Display, not truth (the governing limit of ب١).** Nothing in this file
27
43
  * writes a log event, touches a fingerprint, or decides anything. Chunks are
28
44
  * emitted for a human to look at; the payload the brain returns is the record.
@@ -94,10 +110,16 @@ export function accumulateOpenAI(chunks) {
94
110
  let content = "";
95
111
  let sawText = false;
96
112
  let usage;
113
+ let model;
97
114
  const fragments = new Map();
98
115
  for (const chunk of chunks) {
99
116
  if (chunk.usage)
100
117
  usage = chunk.usage;
118
+ // Every chunk repeats the serving model; the last one wins, exactly as
119
+ // `usage` does. They should all agree — and if a gateway ever re-routed
120
+ // mid-response, the name that answered LAST is the honest one to record.
121
+ if (chunk.model)
122
+ model = chunk.model;
101
123
  const delta = chunk.choices?.[0]?.delta;
102
124
  if (!delta)
103
125
  continue;
@@ -132,6 +154,7 @@ export function accumulateOpenAI(chunks) {
132
154
  },
133
155
  ],
134
156
  ...(usage ? { usage } : {}),
157
+ ...(model !== undefined ? { model } : {}),
135
158
  };
136
159
  }
137
160
  function blockDelta(delta) {
@@ -173,6 +196,7 @@ export function accumulateAnthropic(events) {
173
196
  const blocks = new Map();
174
197
  let inputTokens = 0;
175
198
  let outputTokens = 0;
199
+ let model;
176
200
  for (const event of events) {
177
201
  if (event.type === "message_start") {
178
202
  const usage = event.message?.usage;
@@ -180,6 +204,10 @@ export function accumulateAnthropic(events) {
180
204
  inputTokens = usage.input_tokens;
181
205
  if (typeof usage?.output_tokens === "number")
182
206
  outputTokens = usage.output_tokens;
207
+ // Claude names the serving model once, in the opening event's message —
208
+ // the same object the non-streamed call returns whole.
209
+ if (typeof event.message?.model === "string")
210
+ model = event.message.model;
183
211
  continue;
184
212
  }
185
213
  if (event.type === "message_delta") {
@@ -218,7 +246,11 @@ export function accumulateAnthropic(events) {
218
246
  .map(([, slot]) => slot.type === "tool_use"
219
247
  ? { type: "tool_use", name: slot.name, input: parseJsonObject(slot.json) }
220
248
  : { type: slot.type, text: slot.text });
221
- return { content, usage: { input_tokens: inputTokens, output_tokens: outputTokens } };
249
+ return {
250
+ content,
251
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
252
+ ...(model !== undefined ? { model } : {}),
253
+ };
222
254
  }
223
255
  /**
224
256
  * The text this stream item carries, across ALL of its text parts.
@@ -260,9 +292,14 @@ export function accumulateGemini(items) {
260
292
  const parts = [];
261
293
  let textSlot = -1;
262
294
  let usage;
295
+ let modelVersion;
263
296
  for (const item of items) {
264
297
  if (item.usageMetadata)
265
298
  usage = item.usageMetadata;
299
+ // Every element repeats it (each is a whole `GenerateContentResponse`);
300
+ // last one wins, as with usage.
301
+ if (item.modelVersion)
302
+ modelVersion = item.modelVersion;
266
303
  for (const part of item.candidates?.[0]?.content?.parts ?? []) {
267
304
  if (typeof part.text === "string") {
268
305
  if (textSlot === -1) {
@@ -281,6 +318,7 @@ export function accumulateGemini(items) {
281
318
  return {
282
319
  candidates: [{ content: { parts } }],
283
320
  ...(usage ? { usageMetadata: usage } : {}),
321
+ ...(modelVersion !== undefined ? { modelVersion } : {}),
284
322
  };
285
323
  }
286
324
  /**
@@ -323,6 +361,7 @@ export function accumulateOllama(lines) {
323
361
  const toolCalls = [];
324
362
  let promptEvalCount;
325
363
  let evalCount;
364
+ let model;
326
365
  for (const line of lines) {
327
366
  if (typeof line.message?.content === "string")
328
367
  content += line.message.content;
@@ -333,6 +372,9 @@ export function accumulateOllama(lines) {
333
372
  }
334
373
  if (typeof line.eval_count === "number")
335
374
  evalCount = line.eval_count;
375
+ // Repeated on every line; last one wins, as with the counts.
376
+ if (line.model)
377
+ model = line.model;
336
378
  }
337
379
  return {
338
380
  message: {
@@ -341,6 +383,7 @@ export function accumulateOllama(lines) {
341
383
  },
342
384
  ...(promptEvalCount !== undefined ? { prompt_eval_count: promptEvalCount } : {}),
343
385
  ...(evalCount !== undefined ? { eval_count: evalCount } : {}),
386
+ ...(model !== undefined ? { model } : {}),
344
387
  };
345
388
  }
346
389
  /**