@oh-my-pi/pi-ai 16.2.6 → 16.2.7

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/types/auth-storage.d.ts +11 -10
  3. package/dist/types/providers/__tests__/kimi-code-thinking.test.d.ts +1 -0
  4. package/dist/types/providers/google-auth.d.ts +8 -0
  5. package/dist/types/providers/google-interactions.d.ts +65 -0
  6. package/dist/types/providers/google-shared.d.ts +20 -5
  7. package/dist/types/providers/google-types.d.ts +5 -0
  8. package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
  9. package/dist/types/providers/openai-codex-responses.d.ts +1 -1
  10. package/dist/types/providers/openai-shared.d.ts +3 -3
  11. package/dist/types/types.d.ts +80 -30
  12. package/dist/types/utils/leaked-thinking-stream.d.ts +29 -0
  13. package/package.json +4 -4
  14. package/src/auth-storage.ts +56 -52
  15. package/src/providers/__tests__/kimi-code-thinking.test.ts +112 -0
  16. package/src/providers/anthropic.ts +11 -10
  17. package/src/providers/google-auth.ts +25 -0
  18. package/src/providers/google-gemini-cli.ts +90 -41
  19. package/src/providers/google-interactions.ts +753 -0
  20. package/src/providers/google-shared.ts +74 -13
  21. package/src/providers/google-types.ts +5 -1
  22. package/src/providers/google-vertex.ts +117 -26
  23. package/src/providers/google.ts +61 -19
  24. package/src/providers/openai-chat-server.ts +2 -2
  25. package/src/providers/openai-codex/request-transformer.ts +17 -4
  26. package/src/providers/openai-codex-responses.ts +1 -1
  27. package/src/providers/openai-shared.ts +5 -11
  28. package/src/stream.ts +21 -6
  29. package/src/types.ts +164 -51
  30. package/src/utils/leaked-thinking-stream.ts +260 -0
package/src/stream.ts CHANGED
@@ -71,6 +71,7 @@ import type {
71
71
  ToolChoice,
72
72
  } from "./types";
73
73
  import { AssistantMessageEventStream } from "./utils/event-stream";
74
+ import { wrapLeakedThinkingStream } from "./utils/leaked-thinking-stream";
74
75
  import { wrapFetchForProxy } from "./utils/proxy";
75
76
  import { withRequestDebugFetch } from "./utils/request-debug";
76
77
  import { withGeminiThinkingLoopGuard } from "./utils/thinking-loop";
@@ -499,8 +500,11 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
499
500
  options: TOptions | undefined,
500
501
  dispatch: () => AssistantMessageEventStream,
501
502
  ): AssistantMessageEventStream {
503
+ // Leaked-thinking healing folds in here — the one shared provider-dispatch
504
+ // chokepoint — so the loop guard (which wraps this) sees healed events and all
505
+ // six provider exits are covered by one wrap. Healing is idempotent.
502
506
  const limit = resolveProviderInFlightLimit(model.provider, options);
503
- if (limit === undefined) return dispatch();
507
+ if (limit === undefined) return wrapLeakedThinkingStream(dispatch());
504
508
 
505
509
  const outer = new AssistantMessageEventStream();
506
510
  void (async () => {
@@ -520,7 +524,7 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
520
524
  if (options?.signal?.aborted) {
521
525
  throw options.signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
522
526
  }
523
- const inner = dispatch();
527
+ const inner = wrapLeakedThinkingStream(dispatch());
524
528
  try {
525
529
  for await (const event of inner) {
526
530
  outer.push(event);
@@ -1105,10 +1109,13 @@ export function streamSimple<TApi extends Api>(
1105
1109
 
1106
1110
  // GitLab Duo Workflow - IDE workflow protocol + WebSocket action bridge
1107
1111
  if (model.api === "gitlab-duo-agent") {
1108
- return streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
1109
- ...requestOptions,
1110
- apiKey,
1111
- });
1112
+ // Does not route through withProviderInFlightLimit, so heal explicitly.
1113
+ return wrapLeakedThinkingStream(
1114
+ streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
1115
+ ...requestOptions,
1116
+ apiKey,
1117
+ }),
1118
+ );
1112
1119
  }
1113
1120
 
1114
1121
  // Kimi Code - route to dedicated handler that wraps OpenAI or Anthropic API
@@ -1355,6 +1362,9 @@ function mapOptionsForApi<TApi extends Api>(
1355
1362
  streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
1356
1363
  streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
1357
1364
  providerSessionState: options?.providerSessionState,
1365
+ useInteractionsApi: options?.useInteractionsApi,
1366
+ storeInteraction: options?.storeInteraction,
1367
+ previousInteractionId: options?.previousInteractionId,
1358
1368
  maxInFlightRequests: options?.maxInFlightRequests,
1359
1369
  onPayload: options?.onPayload,
1360
1370
  onResponse: options?.onResponse,
@@ -1565,6 +1575,7 @@ function mapOptionsForApi<TApi extends Api>(
1565
1575
  if (!reasoning || !model.reasoning) {
1566
1576
  return castApi<"google-generative-ai">({
1567
1577
  ...base,
1578
+ serviceTier: options?.serviceTier,
1568
1579
  thinking: { enabled: false },
1569
1580
  toolChoice: mapGoogleToolChoice(options?.toolChoice),
1570
1581
  });
@@ -1578,6 +1589,7 @@ function mapOptionsForApi<TApi extends Api>(
1578
1589
  if (googleModel.thinking?.mode === "google-level") {
1579
1590
  return castApi<"google-generative-ai">({
1580
1591
  ...base,
1592
+ serviceTier: options?.serviceTier,
1581
1593
  thinking: {
1582
1594
  enabled: true,
1583
1595
  level: mapEffortToGoogleThinkingLevel(effort),
@@ -1661,6 +1673,7 @@ function mapOptionsForApi<TApi extends Api>(
1661
1673
  if (!reasoning || !model.reasoning) {
1662
1674
  return castApi<"google-vertex">({
1663
1675
  ...base,
1676
+ serviceTier: options?.serviceTier,
1664
1677
  thinking: { enabled: false },
1665
1678
  toolChoice: mapGoogleToolChoice(options?.toolChoice),
1666
1679
  });
@@ -1673,6 +1686,7 @@ function mapOptionsForApi<TApi extends Api>(
1673
1686
  if (geminiModel.thinking?.mode === "google-level") {
1674
1687
  return castApi<"google-vertex">({
1675
1688
  ...base,
1689
+ serviceTier: options?.serviceTier,
1676
1690
  thinking: {
1677
1691
  enabled: true,
1678
1692
  level: mapEffortToGoogleThinkingLevel(effort),
@@ -1683,6 +1697,7 @@ function mapOptionsForApi<TApi extends Api>(
1683
1697
 
1684
1698
  return castApi<"google-vertex">({
1685
1699
  ...base,
1700
+ serviceTier: options?.serviceTier,
1686
1701
  thinking: {
1687
1702
  enabled: true,
1688
1703
  budgetTokens: getGoogleBudget(geminiModel, effort, options?.thinkingBudgets),
package/src/types.ts CHANGED
@@ -105,84 +105,181 @@ export type ToolChoice =
105
105
  export type CacheRetention = "none" | "short" | "long";
106
106
 
107
107
  /**
108
- * Service tier hint for processing priority / cost control.
108
+ * Service tier hint for processing priority / cost control. These are the
109
+ * values providers consume on the wire:
109
110
  *
110
- * The unscoped values (`"auto"`, `"default"`, `"flex"`, `"scale"`,
111
- * `"priority"`) are passed through to providers that understand them
112
- * (OpenAI's `service_tier` field directly; Anthropic translates
113
- * `"priority"` into `speed: "fast"` on supported Opus models).
111
+ * - OpenAI / OpenAI-Codex: sent verbatim as the `service_tier` field
112
+ * (`flex`/`scale`/`priority`).
113
+ * - Google (Gemini API + Vertex AI): sent as the top-level `serviceTier`
114
+ * field (`flex`/`priority`).
115
+ * - OpenRouter: passed through as `service_tier`; OpenRouter realizes it for
116
+ * the OpenAI- and Google-family upstreams it supports and ignores it
117
+ * otherwise.
118
+ * - Direct Anthropic: `"priority"` is translated into `speed: "fast"` plus the
119
+ * fast-mode beta on supported Opus models. Other tiers are ignored.
114
120
  *
115
- * The scoped values target a specific provider family and behave as the
116
- * unscoped value on the matching provider, or `undefined` everywhere else.
117
- * They let users opt into priority on one family without paying premium
118
- * costs on the other when switching models mid-session.
119
- *
120
- * - `"openai-only"` → `"priority"` on `openai` and `openai-codex`; ignored elsewhere.
121
- * - `"claude-only"` → `"priority"` on direct `anthropic` (not Bedrock/Vertex Claude).
121
+ * Per-family scoping is expressed by {@link ServiceTierByFamily}, not by
122
+ * scoped sentinel values see {@link serviceTierFamily}.
122
123
  */
123
- export type ServiceTier = "auto" | "default" | "flex" | "scale" | "priority" | "openai-only" | "claude-only";
124
+ export type ServiceTier = "auto" | "default" | "flex" | "scale" | "priority";
124
125
 
125
- /** Resolved tier — one of the values that providers actually consume on the wire. */
126
- export type ResolvedServiceTier = Exclude<ServiceTier, "openai-only" | "claude-only">;
126
+ /** Provider families that expose an independent service-tier knob. */
127
+ export type ServiceTierFamily = "openai" | "anthropic" | "google";
127
128
 
128
129
  /**
129
- * Resolves a possibly scoped `ServiceTier` to the effective tier for the
130
- * given provider. Scoped values match their target family and otherwise
131
- * collapse to `undefined`; unscoped values pass through unchanged.
130
+ * Per-family service-tier selection. A request consults only the entry for the
131
+ * family its model belongs to (see {@link resolveModelServiceTier}), so a user
132
+ * can opt one family into priority without affecting the others when switching
133
+ * models mid-session.
132
134
  */
133
- export function resolveServiceTier(
134
- serviceTier: ServiceTier | null | undefined,
135
- provider: Provider | undefined,
136
- ): ResolvedServiceTier | undefined {
137
- if (!serviceTier) return undefined;
138
- switch (serviceTier) {
139
- case "openai-only":
140
- return provider === "openai" || provider === "openai-codex" ? "priority" : undefined;
141
- case "claude-only":
142
- return provider === "anthropic" ? "priority" : undefined;
143
- default:
144
- return serviceTier;
135
+ export type ServiceTierByFamily = Partial<Record<ServiceTierFamily, ServiceTier>>;
136
+
137
+ /**
138
+ * Classify a model into the service-tier family whose knob governs it, or
139
+ * `undefined` when the model exposes no serving-priority control.
140
+ *
141
+ * OpenRouter models are classified by id namespace (`anthropic/`, `google/`,
142
+ * `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
143
+ * anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
144
+ */
145
+ export function serviceTierFamily(model: Pick<Model, "provider" | "api" | "id">): ServiceTierFamily | undefined {
146
+ const provider = model.provider;
147
+ if (provider === "openrouter") {
148
+ const id = model.id.toLowerCase();
149
+ if (id.startsWith("anthropic/")) return "anthropic";
150
+ if (id.startsWith("google/")) return "google";
151
+ if (id.startsWith("openai/")) return "openai";
152
+ return undefined;
145
153
  }
154
+ if (provider === "openai" || provider === "openai-codex") return "openai";
155
+ if (model.api === "anthropic-messages") return "anthropic";
156
+ if (provider === "google" || provider === "google-vertex") return "google";
157
+ return undefined;
146
158
  }
147
159
 
148
160
  /**
149
- * True when the (possibly scoped) tier should be sent on the wire as the
150
- * `service_tier` request field for the given provider. OpenAI / OpenAI-Codex
151
- * accept `flex`/`scale`/`priority`; Fireworks Serverless realizes only its
152
- * Priority serving path (`service_tier: "priority"`) on the OpenAI-compatible
153
- * chat-completions endpoint. Unsupported tiers (`"auto"`, `"default"`), other
154
- * providers, and scope mismatches all return false.
161
+ * Reduce a per-family tier map to the single wire tier for `model` the entry
162
+ * for the model's family, or `undefined` when the model has no family.
163
+ */
164
+ export function resolveModelServiceTier(
165
+ tiers: ServiceTierByFamily | null | undefined,
166
+ model: Pick<Model, "provider" | "api" | "id">,
167
+ ): ServiceTier | undefined {
168
+ if (!tiers) return undefined;
169
+ const family = serviceTierFamily(model);
170
+ return family ? tiers[family] : undefined;
171
+ }
172
+
173
+ /**
174
+ * True when the tier should be sent on the wire as the provider's service-tier
175
+ * request field. OpenAI / OpenAI-Codex accept `flex`/`scale`/`priority`; Google
176
+ * (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`; Fireworks
177
+ * Serverless realizes only its Priority serving path. Anthropic is absent — it
178
+ * realizes `priority` via `speed: "fast"`, not a service-tier field.
155
179
  */
156
180
  export function shouldSendServiceTier(
157
181
  serviceTier: ServiceTier | null | undefined,
158
182
  provider: Provider | undefined,
159
183
  ): boolean {
160
- const resolved = resolveServiceTier(serviceTier, provider);
161
- if (provider === "openai" || provider === "openai-codex") {
162
- return resolved === "flex" || resolved === "scale" || resolved === "priority";
184
+ if (!serviceTier) return false;
185
+ if (provider === "openai" || provider === "openai-codex" || provider === "openrouter") {
186
+ return serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority";
187
+ }
188
+ if (provider === "google") {
189
+ return serviceTier === "flex" || serviceTier === "priority";
163
190
  }
164
- if (provider === "fireworks") {
165
- return resolved === "priority";
191
+ // Vertex realizes only priority (via header); flex has no documented control.
192
+ if (provider === "google-vertex" || provider === "fireworks") {
193
+ return serviceTier === "priority";
166
194
  }
167
195
  return false;
168
196
  }
169
197
 
170
198
  /**
171
- * Premium-request weight contributed by sending priority to a provider
172
- * that supports it. Mirrors GitHub Copilot's `premiumRequests` accounting
173
- * so the "premium requests" stat aggregates priority traffic across the
174
- * OpenAI family and Anthropic fast-mode realizations.
199
+ * True when `priority` will actually be realized on the wire for `model`.
200
+ * Direct Anthropic realizes fast mode; OpenAI/Google/Fireworks emit the
201
+ * service-tier field; OpenRouter realizes it only for its OpenAI- and
202
+ * Google-family upstreams. Bedrock/Vertex Claude and OpenRouter Anthropic
203
+ * models do not realize priority and return `false`.
204
+ */
205
+ export function realizesPriorityServiceTier(
206
+ serviceTier: ServiceTier | null | undefined,
207
+ model: Pick<Model, "provider" | "api" | "id">,
208
+ ): boolean {
209
+ if (serviceTier !== "priority") return false;
210
+ if (model.provider === "anthropic") return true;
211
+ if (model.provider === "openrouter") {
212
+ const family = serviceTierFamily(model);
213
+ return family === "openai" || family === "google";
214
+ }
215
+ if (model.api === "anthropic-messages") return false;
216
+ return shouldSendServiceTier(serviceTier, model.provider);
217
+ }
218
+
219
+ /**
220
+ * Premium-request weight contributed by a priority request to a provider that
221
+ * realizes it and bills extra. Mirrors GitHub Copilot's `premiumRequests`
222
+ * accounting so the "premium requests" stat aggregates priority traffic across
223
+ * the OpenAI family, direct Anthropic fast mode, and Google priority.
175
224
  *
176
- * Returns 1 per resolved priority request, 0 otherwise.
225
+ * Returns 1 only when priority is actually realized on the wire for `model`
226
+ * (see {@link realizesPriorityServiceTier}) and the provider bills it as a
227
+ * premium request. OpenRouter is excluded — it bills per its own pricing, not
228
+ * Copilot-premium semantics — as are Bedrock/Vertex Claude, where priority is
229
+ * silently dropped.
177
230
  */
178
231
  export function getPriorityPremiumRequests(
179
232
  serviceTier: ServiceTier | null | undefined,
180
- provider: Provider | undefined,
233
+ model: Pick<Model, "provider" | "api" | "id">,
181
234
  ): number {
182
- if (resolveServiceTier(serviceTier, provider) !== "priority") return 0;
183
- // Only providers that realize `priority` on the wire bill the user.
184
- // Everywhere else, the field is silently dropped and nothing is charged.
185
- return provider === "openai" || provider === "openai-codex" || provider === "anthropic" ? 1 : 0;
235
+ if (!realizesPriorityServiceTier(serviceTier, model)) return 0;
236
+ const provider = model.provider;
237
+ return provider === "openai" ||
238
+ provider === "openai-codex" ||
239
+ provider === "anthropic" ||
240
+ provider === "google" ||
241
+ provider === "google-vertex"
242
+ ? 1
243
+ : 0;
244
+ }
245
+
246
+ /**
247
+ * Coerce a persisted service-tier value to a {@link ServiceTierByFamily}. Newer
248
+ * sessions store the family map directly; legacy sessions stored a single
249
+ * scalar — `"priority"` applied everywhere, `"openai-only"`/`"claude-only"`
250
+ * scoped to one family, and the remaining values were OpenAI-only semantics.
251
+ */
252
+ export function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily | undefined {
253
+ if (value === null || value === undefined) return undefined;
254
+ if (typeof value === "object") {
255
+ const src = value as Record<string, unknown>;
256
+ const out: ServiceTierByFamily = {};
257
+ for (const family of ["openai", "anthropic", "google"] as const) {
258
+ const tier = src[family];
259
+ if (tier === "auto" || tier === "default" || tier === "flex" || tier === "scale" || tier === "priority") {
260
+ out[family] = tier;
261
+ }
262
+ }
263
+ return Object.keys(out).length > 0 ? out : undefined;
264
+ }
265
+ switch (value) {
266
+ case "priority":
267
+ return { openai: "priority", anthropic: "priority", google: "priority" };
268
+ case "openai-only":
269
+ return { openai: "priority" };
270
+ case "claude-only":
271
+ return { anthropic: "priority" };
272
+ case "auto":
273
+ return { openai: "auto" };
274
+ case "default":
275
+ return { openai: "default" };
276
+ case "flex":
277
+ return { openai: "flex" };
278
+ case "scale":
279
+ return { openai: "scale" };
280
+ default:
281
+ return undefined;
282
+ }
186
283
  }
187
284
 
188
285
  export interface ProviderSessionState {
@@ -277,6 +374,22 @@ export interface StreamOptions {
277
374
  * Providers can use this to persist transport/session state between turns.
278
375
  */
279
376
  providerSessionState?: Map<string, ProviderSessionState>;
377
+ /**
378
+ * Force Gemini model-mode Interactions API transport for providers that support it.
379
+ * When unset, those providers may still use Interactions to continue known
380
+ * server-side conversation lineage via `previousInteractionId` or stored state.
381
+ */
382
+ useInteractionsApi?: boolean;
383
+ /**
384
+ * Whether supported Interactions transports should store server-side conversation
385
+ * state and return response ids for follow-up turns. Defaults to true.
386
+ */
387
+ storeInteraction?: boolean;
388
+ /**
389
+ * Explicit Interactions response id to continue. Mutually exclusive with
390
+ * `storeInteraction: false` because the follow-up itself must be storable.
391
+ */
392
+ previousInteractionId?: string;
280
393
  /**
281
394
  * Optional per-provider concurrent request cap for LLM stream calls. Keys are
282
395
  * provider ids (`model.provider`); positive numeric values cap in-flight
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Central live healing for leaked reasoning markup in the visible text channel.
3
+ *
4
+ * Some providers emit their canonical reasoning idioms (` ```thinking `,
5
+ * `<think>`, Gemma/Harmony channels, …) into the *visible* text stream instead
6
+ * of a structured thinking part. {@link wrapLeakedThinkingStream} re-projects any
7
+ * provider stream into a fresh {@link AssistantMessageEventStream}, splitting the
8
+ * leaked fences out into proper `thinking` blocks *live* as deltas arrive — so
9
+ * every provider gets the same healing, not just the three with provider-local
10
+ * {@link StreamMarkupHealing} loops.
11
+ *
12
+ * The healing is idempotent: a second pass over already-clean text finds no
13
+ * fences, so wrapping a provider that already heals (or wrapping twice) is a
14
+ * harmless pass-through. Signatures are load-bearing for Google/Gemini/Vertex
15
+ * thought round-tripping, so text sub-blocks carry the source `textSignature`,
16
+ * forwarded thinking blocks their `thinkingSignature`, and forwarded tool calls
17
+ * their `thoughtSignature`.
18
+ *
19
+ * Modeled on {@link wrapInbandToolStream} / `InbandStreamProjector` in
20
+ * `../dialect/owned-stream.ts`, minus all in-band tool-call grammar: tool-call
21
+ * events are forwarded verbatim.
22
+ */
23
+
24
+ import type { AssistantMessage, TextContent, ThinkingContent, ToolCall } from "../types";
25
+ import { AssistantMessageEventStream } from "./event-stream";
26
+ import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "./stream-markup-healing";
27
+
28
+ /**
29
+ * Wrap a provider stream so leaked reasoning fences are healed into thinking
30
+ * blocks live, for every provider. Returns a new stream that re-projects the
31
+ * inner one; the inner stream is fully consumed.
32
+ */
33
+ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): AssistantMessageEventStream {
34
+ const out = new AssistantMessageEventStream();
35
+ void (async () => {
36
+ try {
37
+ let projector: LeakedThinkingProjector | undefined;
38
+ for await (const event of inner) {
39
+ switch (event.type) {
40
+ case "start":
41
+ projector = new LeakedThinkingProjector(out, event.partial);
42
+ break;
43
+ case "text_delta": {
44
+ projector ??= new LeakedThinkingProjector(out, event.partial);
45
+ const block = event.partial.content[event.contentIndex];
46
+ projector.text(event.delta, block?.type === "text" ? block.textSignature : undefined);
47
+ break;
48
+ }
49
+ case "thinking_delta": {
50
+ projector ??= new LeakedThinkingProjector(out, event.partial);
51
+ const block = event.partial.content[event.contentIndex];
52
+ projector.thinking(event.delta, block?.type === "thinking" ? block.thinkingSignature : undefined);
53
+ break;
54
+ }
55
+ case "toolcall_start": {
56
+ projector ??= new LeakedThinkingProjector(out, event.partial);
57
+ const block = event.partial.content[event.contentIndex];
58
+ projector.toolStart(event.contentIndex, block?.type === "toolCall" ? block.name : "");
59
+ break;
60
+ }
61
+ case "toolcall_delta":
62
+ projector?.toolDelta(event.contentIndex, event.delta);
63
+ break;
64
+ case "toolcall_end":
65
+ projector?.toolEnd(event.contentIndex, event.toolCall);
66
+ break;
67
+ case "done": {
68
+ projector ??= new LeakedThinkingProjector(out, event.message);
69
+ const content = projector.finish(event.message);
70
+ out.push({ type: "done", reason: event.reason, message: { ...event.message, content } });
71
+ return;
72
+ }
73
+ case "error": {
74
+ projector ??= new LeakedThinkingProjector(out, event.error);
75
+ const content = projector.finish(event.error);
76
+ out.push({ type: "error", reason: event.reason, error: { ...event.error, content } });
77
+ return;
78
+ }
79
+ // text_start/text_end/thinking_start/thinking_end are ignored: the
80
+ // projector owns block boundaries (matches wrapInbandToolStream).
81
+ }
82
+ }
83
+ // Inner ended via end(result) without a terminal event.
84
+ if (!out.done) {
85
+ const result = await inner.result();
86
+ projector ??= new LeakedThinkingProjector(out, result);
87
+ const content = projector.finish(result);
88
+ out.end({ ...result, content });
89
+ }
90
+ } catch (err) {
91
+ if (!out.done) out.fail(err);
92
+ }
93
+ })();
94
+ return out;
95
+ }
96
+
97
+ type OpenBlock = { index: number } | undefined;
98
+
99
+ /**
100
+ * Re-projects an inner stream's events into `out`, healing leaked reasoning out
101
+ * of the visible text channel while forwarding native thinking and tool calls.
102
+ */
103
+ class LeakedThinkingProjector {
104
+ readonly #out: AssistantMessageEventStream;
105
+ readonly #healer = new StreamMarkupHealing({ pattern: "thinking" });
106
+ #partial: AssistantMessage;
107
+ #text: OpenBlock;
108
+ #thinking: OpenBlock;
109
+ /** Total visible text length fed to the healer, to replay any un-streamed tail in {@link finish}. */
110
+ #fedLen = 0;
111
+ /** Latest non-undefined text signature seen, stamped onto held-back text flushed later. */
112
+ #lastTextSignature: string | undefined;
113
+ /** Forwarded native tool calls, keyed by the inner stream's `contentIndex`. */
114
+ #toolBlocks = new Map<number, { index: number }>();
115
+
116
+ constructor(out: AssistantMessageEventStream, seed: AssistantMessage) {
117
+ this.#out = out;
118
+ this.#partial = { ...seed, content: [] };
119
+ this.#out.push({ type: "start", partial: this.#partial });
120
+ }
121
+
122
+ /** Feed a visible-text delta through the healer, splitting leaked fences live. */
123
+ text(delta: string, signature: string | undefined): void {
124
+ this.#fedLen += delta.length;
125
+ if (signature !== undefined) this.#lastTextSignature = signature;
126
+ this.#apply(this.#healer.feedEvents(delta), this.#lastTextSignature);
127
+ }
128
+
129
+ /** Forward a native thinking delta, preserving its signature. */
130
+ thinking(delta: string, signature: string | undefined): void {
131
+ const index = this.#openThinking();
132
+ const block = this.#partial.content[index] as ThinkingContent;
133
+ block.thinking += delta;
134
+ if (signature !== undefined) block.thinkingSignature = signature;
135
+ this.#out.push({ type: "thinking_delta", contentIndex: index, delta, partial: this.#partial });
136
+ }
137
+
138
+ /** Forward a native tool call's start, releasing any held-back text first. */
139
+ toolStart(srcIndex: number, name: string): void {
140
+ this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
141
+ this.#closeText();
142
+ this.#closeThinking();
143
+ const block: ToolCall = { type: "toolCall", id: "", name, arguments: {} };
144
+ this.#partial.content.push(block);
145
+ const index = this.#partial.content.length - 1;
146
+ this.#toolBlocks.set(srcIndex, { index });
147
+ this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
148
+ }
149
+
150
+ toolDelta(srcIndex: number, delta: string): void {
151
+ const entry = this.#toolBlocks.get(srcIndex);
152
+ if (!entry) return;
153
+ this.#out.push({ type: "toolcall_delta", contentIndex: entry.index, delta, partial: this.#partial });
154
+ }
155
+
156
+ toolEnd(srcIndex: number, toolCall: ToolCall): void {
157
+ const entry = this.#toolBlocks.get(srcIndex);
158
+ if (entry) {
159
+ const block = this.#partial.content[entry.index] as ToolCall;
160
+ Object.assign(block, toolCall);
161
+ this.#out.push({ type: "toolcall_end", contentIndex: entry.index, toolCall: block, partial: this.#partial });
162
+ this.#toolBlocks.delete(srcIndex);
163
+ return;
164
+ }
165
+ // `end` without a matching `start` — release held text, then forward whole.
166
+ this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
167
+ this.#closeText();
168
+ this.#closeThinking();
169
+ const block: ToolCall = { ...toolCall };
170
+ this.#partial.content.push(block);
171
+ const index = this.#partial.content.length - 1;
172
+ this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
173
+ this.#out.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: this.#partial });
174
+ }
175
+
176
+ /**
177
+ * Finalize: replay any un-streamed visible-text tail from `message.content`,
178
+ * flush held-back fragments, close open blocks, and return the healed content.
179
+ */
180
+ finish(message: AssistantMessage): AssistantMessage["content"] {
181
+ let fullText = "";
182
+ let tailSignature: string | undefined;
183
+ for (const block of message.content) {
184
+ if (block.type === "text") {
185
+ fullText += block.text;
186
+ tailSignature = block.textSignature;
187
+ }
188
+ }
189
+ if (tailSignature !== undefined) this.#lastTextSignature = tailSignature;
190
+ if (fullText.length > this.#fedLen) {
191
+ this.#apply(this.#healer.feedEvents(fullText.slice(this.#fedLen)), this.#lastTextSignature);
192
+ }
193
+ this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
194
+ this.#closeText();
195
+ this.#closeThinking();
196
+ return this.#partial.content;
197
+ }
198
+
199
+ #apply(events: readonly StreamMarkupHealingEvent[], signature?: string): void {
200
+ for (const event of events) {
201
+ if (event.type === "text") this.#emitText(event.text, signature);
202
+ else if (event.type === "thinking") this.#emitHealedThinking(event.thinking);
203
+ }
204
+ }
205
+
206
+ #emitText(text: string, signature: string | undefined): void {
207
+ if (text.length === 0) return;
208
+ this.#closeThinking();
209
+ if (!this.#text) {
210
+ const block: TextContent =
211
+ signature === undefined ? { type: "text", text: "" } : { type: "text", text: "", textSignature: signature };
212
+ this.#partial.content.push(block);
213
+ this.#text = { index: this.#partial.content.length - 1 };
214
+ this.#out.push({ type: "text_start", contentIndex: this.#text.index, partial: this.#partial });
215
+ } else if (signature !== undefined) {
216
+ (this.#partial.content[this.#text.index] as TextContent).textSignature = signature;
217
+ }
218
+ const block = this.#partial.content[this.#text.index] as TextContent;
219
+ block.text += text;
220
+ this.#out.push({ type: "text_delta", contentIndex: this.#text.index, delta: text, partial: this.#partial });
221
+ }
222
+
223
+ /** Healed (leaked) thinking carries no signature, matching the source fence. */
224
+ #emitHealedThinking(text: string): void {
225
+ if (text.length === 0) return;
226
+ const index = this.#openThinking();
227
+ const block = this.#partial.content[index] as ThinkingContent;
228
+ block.thinking += text;
229
+ this.#out.push({ type: "thinking_delta", contentIndex: index, delta: text, partial: this.#partial });
230
+ }
231
+
232
+ #openThinking(): number {
233
+ this.#closeText();
234
+ if (!this.#thinking) {
235
+ this.#partial.content.push({ type: "thinking", thinking: "" });
236
+ this.#thinking = { index: this.#partial.content.length - 1 };
237
+ this.#out.push({ type: "thinking_start", contentIndex: this.#thinking.index, partial: this.#partial });
238
+ }
239
+ return this.#thinking.index;
240
+ }
241
+
242
+ #closeText(): void {
243
+ if (!this.#text) return;
244
+ const block = this.#partial.content[this.#text.index] as TextContent;
245
+ this.#out.push({ type: "text_end", contentIndex: this.#text.index, content: block.text, partial: this.#partial });
246
+ this.#text = undefined;
247
+ }
248
+
249
+ #closeThinking(): void {
250
+ if (!this.#thinking) return;
251
+ const block = this.#partial.content[this.#thinking.index] as ThinkingContent;
252
+ this.#out.push({
253
+ type: "thinking_end",
254
+ contentIndex: this.#thinking.index,
255
+ content: block.thinking,
256
+ partial: this.#partial,
257
+ });
258
+ this.#thinking = undefined;
259
+ }
260
+ }