@oh-my-pi/pi-ai 17.0.7 → 17.0.8

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
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+
7
+ - Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority` ([#5160](https://github.com/can1357/oh-my-pi/pull/5160) by [@audreyt](https://github.com/audreyt)).
8
+
9
+ ## [17.0.8] - 2026-07-22
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Gemini Flash Cloud Code Assist empty-response retries when responses contain only intercepted planning-leak JSON.
14
+ - Fixed Antigravity auto-routing to correctly fail over to the sandbox endpoint when the daily endpoint exhausts its retries.
15
+ - Fixed OpenAI-compatible providers configured with auth: none incorrectly sending an Authorization: Bearer N/A header, which broke custom endpoints using alternative authentication headers.
16
+ - Fixed auth-gateway model listings exposing duplicate or ambiguous model IDs by ensuring only provider-qualified routing IDs are advertised.
17
+ - Improved connection error handling by classifying generic connection failures as transient, allowing them to be retried, while keeping explicit authentication rejections non-retryable.
18
+ - Fixed custom Anthropic base URLs losing native thinking signatures during continuation requests.
19
+ - Fixed Alibaba Coding Plan Custom login rejecting valid API keys on endpoints that do not serve the default validation model by validating against the model catalog instead.
20
+
5
21
  ## [17.0.6] - 2026-07-20
6
22
 
7
23
  ### Fixed
@@ -73,7 +73,13 @@ export declare function classifyMessage(message: {
73
73
  export declare function attach<E extends object>(error: E, id: number): E;
74
74
  export declare function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean;
75
75
  export declare function stringify(id: number | undefined): string;
76
- /** Transient stream corruption where the response was truncated mid-JSON. */
76
+ /**
77
+ * Transient stream corruption where the response was truncated mid-JSON.
78
+ *
79
+ * Strings (persisted `stopDetails.explanation`/`errorMessage` diagnostics) are matched with the
80
+ * stricter {@link STREAM_PARSE_DIAGNOSTIC_PATTERN} — bare "truncated"/"end of file" text is too
81
+ * low-signal to trust once detached from a live transport `Error`, which keeps the broad pattern.
82
+ */
77
83
  export declare function isTransientStreamParseError(error: unknown): boolean;
78
84
  /** Any malformed stream-envelope error (prefix-tagged or out-of-order events). */
79
85
  export declare function isStreamEnvelopeError(error: unknown): boolean;
@@ -7,6 +7,15 @@ import type { CapturedHttpErrorResponse } from "../utils/http-inspector.js";
7
7
  import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire.js";
8
8
  import type { InputItem } from "./openai-codex/request-transformer.js";
9
9
  import type { ResponseContentPartAddedEvent, ResponseCreateParamsStreaming, ResponseInput, ResponseInputContent, ResponseInputItem, ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseStatus, ResponseStreamEvent } from "./openai-responses-wire.js";
10
+ /**
11
+ * Keyless-provider sentinel. Custom providers configured with `auth: none`
12
+ * (models.yml) have no credential, so the coding-agent resolves their API key
13
+ * to this literal instead of a real secret. Providers must treat it as "no
14
+ * credential" and suppress any credential-bearing header (e.g. `Authorization:
15
+ * Bearer …`) rather than forwarding the sentinel on the wire. See #6188; the
16
+ * google-vertex and amazon-bedrock transports apply the same guard inline.
17
+ */
18
+ export declare const NO_AUTH_SENTINEL = "N/A";
10
19
  export interface OpenAIModelIdentity {
11
20
  provider: string;
12
21
  id: string;
@@ -108,7 +108,8 @@ type ServiceTierModel = Pick<Model, "provider" | "api" | "id">;
108
108
  * `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
109
109
  * anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
110
110
  * Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family
111
- * too unless that provider owns a separate tier control such as Fireworks.
111
+ * too unless the provider owns a separate tier control (Fireworks) or rejects
112
+ * OpenAI's service-tier field (GitHub Copilot).
112
113
  */
113
114
  export declare function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined;
114
115
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.0.7",
4
+ "version": "17.0.8",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.0.7",
42
- "@oh-my-pi/pi-utils": "17.0.7",
43
- "@oh-my-pi/pi-wire": "17.0.7",
41
+ "@oh-my-pi/pi-catalog": "17.0.8",
42
+ "@oh-my-pi/pi-utils": "17.0.8",
43
+ "@oh-my-pi/pi-wire": "17.0.8",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -726,13 +726,19 @@ async function handleCredentialsCheck(storage: AuthStorage, signal: AbortSignal)
726
726
  }
727
727
 
728
728
  function handleModelsList(opts: AuthGatewayBootOptions): Response {
729
- const list = opts.listModels ? Array.from(opts.listModels()) : [];
730
- const data = list.map(model => ({
731
- id: model.id,
732
- object: "model" as const,
733
- owned_by: model.provider,
734
- api: model.api,
735
- }));
729
+ const seen = new Set<string>();
730
+ const data: Array<{ id: string; object: "model"; owned_by: string; api: Api }> = [];
731
+ for (const model of opts.listModels?.() ?? []) {
732
+ const id = `${model.provider}/${model.id}`;
733
+ if (seen.has(id)) continue;
734
+ seen.add(id);
735
+ data.push({
736
+ id,
737
+ object: "model",
738
+ owned_by: model.provider,
739
+ api: model.api,
740
+ });
741
+ }
736
742
  return json(200, { object: "list", data });
737
743
  }
738
744
 
@@ -90,7 +90,7 @@ const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
90
90
  const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i;
91
91
  export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
92
92
  export const TRANSIENT_TRANSPORT_PATTERN =
93
- /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
93
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
94
94
  const AUTH_FAILURE_PATTERN =
95
95
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
96
96
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
@@ -505,10 +505,19 @@ export function stringify(id: number | undefined): string {
505
505
 
506
506
  const STREAM_PARSE_TRUNCATION_PATTERN =
507
507
  /unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i;
508
+ const STREAM_PARSE_DIAGNOSTIC_PATTERN =
509
+ /(?:json parse error:\s*(?:unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated)|json\.parse:\s*(?:unterminated string|unexpected end of data)|unexpected end of json input|unexpected eof|eof while parsing)/i;
508
510
  const STREAM_EVENT_ORDER_PATTERN = /stream event order|before message_start/i;
509
511
 
510
- /** Transient stream corruption where the response was truncated mid-JSON. */
512
+ /**
513
+ * Transient stream corruption where the response was truncated mid-JSON.
514
+ *
515
+ * Strings (persisted `stopDetails.explanation`/`errorMessage` diagnostics) are matched with the
516
+ * stricter {@link STREAM_PARSE_DIAGNOSTIC_PATTERN} — bare "truncated"/"end of file" text is too
517
+ * low-signal to trust once detached from a live transport `Error`, which keeps the broad pattern.
518
+ */
511
519
  export function isTransientStreamParseError(error: unknown): boolean {
520
+ if (typeof error === "string") return STREAM_PARSE_DIAGNOSTIC_PATTERN.test(error);
512
521
  return error instanceof Error && STREAM_PARSE_TRUNCATION_PATTERN.test(error.message);
513
522
  }
514
523
 
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { NO_AUTH_SENTINEL, resolveOpenAIRequestSetup } from "../openai-shared";
3
+
4
+ describe("resolveOpenAIRequestSetup keyless auth", () => {
5
+ test("omits Authorization for the keyless (auth: none) sentinel, keeping custom headers", () => {
6
+ const setup = resolveOpenAIRequestSetup(
7
+ {
8
+ provider: "qwen",
9
+ id: "Qwen3.6-35B-A3B",
10
+ baseUrl: "http://localhost:8788",
11
+ headers: { "x-api-key": "real-key" },
12
+ },
13
+ { apiKey: NO_AUTH_SENTINEL, messages: [] },
14
+ );
15
+ expect(setup.headers.Authorization).toBeUndefined();
16
+ expect(setup.headers["x-api-key"]).toBe("real-key");
17
+ });
18
+
19
+ test("still injects Bearer for a real key", () => {
20
+ const setup = resolveOpenAIRequestSetup(
21
+ { provider: "custom", id: "m", baseUrl: "http://localhost:8788" },
22
+ { apiKey: "sk-real", messages: [] },
23
+ );
24
+ expect(setup.headers.Authorization).toBe("Bearer sk-real");
25
+ });
26
+
27
+ test("caller-supplied Authorization in model.headers is preserved even when keyless", () => {
28
+ const setup = resolveOpenAIRequestSetup(
29
+ {
30
+ provider: "qwen",
31
+ id: "m",
32
+ baseUrl: "http://localhost:8788",
33
+ headers: { Authorization: "Bearer custom-token" },
34
+ },
35
+ { apiKey: NO_AUTH_SENTINEL, messages: [] },
36
+ );
37
+ expect(setup.headers.Authorization).toBe("Bearer custom-token");
38
+ });
39
+ });
@@ -161,7 +161,7 @@ describe("Kimi K3 thinking transport", () => {
161
161
  it("downgrades named tool choice to required for K3 thinking", async () => {
162
162
  vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
163
163
  const bundledModel = getBundledModel<"openai-completions">("kimi-code", "k3");
164
- expect(bundledModel.compat.thinkingFormat).toBe("zai");
164
+ expect(bundledModel.compat.thinkingFormat).toBe("kimi");
165
165
  let payload: unknown;
166
166
  const capturePayload = async (
167
167
  model: Model<"openai-completions">,
@@ -815,9 +815,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
815
815
  if (isBuffering) {
816
816
  const buffered = consumePlanningBuffer(textBuffer, toolNames);
817
817
  if (buffered.kind !== "incomplete") {
818
- if (buffered.kind === "leak") {
819
- sawLeak = true;
820
- }
821
818
  const visibleSignature = bufferedTextSignature;
822
819
  isBuffering = false;
823
820
  textBuffer = "";
@@ -896,9 +893,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
896
893
 
897
894
  if (isBuffering && textBuffer !== "") {
898
895
  const buffered = consumePlanningBuffer(textBuffer, toolNames, true);
899
- if (buffered.kind === "leak") {
900
- sawLeak = true;
901
- }
896
+
902
897
  if (buffered.kind !== "incomplete") {
903
898
  feedVisibleText(buffered.visibleText, bufferedTextSignature);
904
899
  }
@@ -910,11 +905,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
910
905
  flushVisibleText(bufferedTextSignature);
911
906
  endCurrentBlock();
912
907
 
913
- return hasMeaningfulGoogleContent(output) || sawLeak;
908
+ return hasMeaningfulGoogleContent(output);
914
909
  };
915
910
 
916
911
  let receivedContent = false;
917
- let sawLeak = false;
918
912
 
919
913
  for (let i = 0; i < endpoints.length; i++) {
920
914
  const endpoint = endpoints[i];
@@ -1055,10 +1049,15 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
1055
1049
  break;
1056
1050
  } catch (error) {
1057
1051
  const status = extractHttpStatusFromError(error);
1058
- if (AIError.isTransientStatus(status)) {
1059
- if (!isLastEndpoint && !started) {
1060
- continue;
1061
- }
1052
+ if (
1053
+ !isLastEndpoint &&
1054
+ !started &&
1055
+ (AIError.isTransientStatus(status) ||
1056
+ (status === undefined &&
1057
+ !(error instanceof AIError.ProviderResponseError && error.kind === "output") &&
1058
+ AIError.retriable(AIError.classify(error))))
1059
+ ) {
1060
+ continue;
1062
1061
  }
1063
1062
  throw error;
1064
1063
  }
@@ -101,6 +101,16 @@ import type {
101
101
  import { transformMessages } from "./transform-messages";
102
102
  import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
103
103
 
104
+ /**
105
+ * Keyless-provider sentinel. Custom providers configured with `auth: none`
106
+ * (models.yml) have no credential, so the coding-agent resolves their API key
107
+ * to this literal instead of a real secret. Providers must treat it as "no
108
+ * credential" and suppress any credential-bearing header (e.g. `Authorization:
109
+ * Bearer …`) rather than forwarding the sentinel on the wire. See #6188; the
110
+ * google-vertex and amazon-bedrock transports apply the same guard inline.
111
+ */
112
+ export const NO_AUTH_SENTINEL = "N/A";
113
+
104
114
  export interface OpenAIModelIdentity {
105
115
  provider: string;
106
116
  id: string;
@@ -279,7 +289,15 @@ export function resolveOpenAIRequestSetup(
279
289
  baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl);
280
290
  }
281
291
  const requestHeaders = { ...headers };
282
- headers.Authorization ??= `Bearer ${apiKey}`;
292
+ // A keyless provider (`auth: none` in models.yml) resolves to the `N/A`
293
+ // sentinel rather than a real key. Injecting `Authorization: Bearer N/A`
294
+ // breaks custom endpoints that authenticate via their own headers (e.g.
295
+ // `headers.x-api-key`) and reject the bogus bearer — mirror the sentinel
296
+ // guards in google-vertex / amazon-bedrock and send no Authorization here
297
+ // (#6188). A caller-supplied Authorization in `model.headers` still wins.
298
+ if (apiKey !== NO_AUTH_SENTINEL) {
299
+ headers.Authorization ??= `Bearer ${apiKey}`;
300
+ }
283
301
  return { copilotPremiumRequests, baseUrl, headers, query, requestHeaders };
284
302
  }
285
303
 
@@ -71,13 +71,22 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise<
71
71
  }
72
72
 
73
73
  options.onProgress?.("Validating API key...");
74
- await apiKeyValidation.validateOpenAICompatibleApiKey({
75
- provider: "Alibaba Coding Plan",
76
- apiKey: trimmed,
77
- baseUrl,
78
- model: VALIDATION_MODEL,
79
- signal: options.signal,
80
- });
74
+ if (choice === "3") {
75
+ await apiKeyValidation.validateApiKeyAgainstModelsEndpoint({
76
+ provider: "Alibaba Coding Plan",
77
+ apiKey: trimmed,
78
+ modelsUrl: `${baseUrl}/models`,
79
+ signal: options.signal,
80
+ });
81
+ } else {
82
+ await apiKeyValidation.validateOpenAICompatibleApiKey({
83
+ provider: "Alibaba Coding Plan",
84
+ apiKey: trimmed,
85
+ baseUrl,
86
+ model: VALIDATION_MODEL,
87
+ signal: options.signal,
88
+ });
89
+ }
81
90
 
82
91
  return {
83
92
  access: trimmed,
package/src/types.ts CHANGED
@@ -141,13 +141,17 @@ function isOpenAIServiceTierApi(api: Api | undefined): boolean {
141
141
  return api === "openai-completions" || api === "openai-responses" || api === "openai-codex-responses";
142
142
  }
143
143
 
144
- function hasDedicatedServiceTierControl(provider: Provider | undefined): boolean {
145
- return provider === "fireworks";
144
+ function excludesInferredOpenAIServiceTier(provider: Provider | undefined): boolean {
145
+ // Fireworks has its own priority-only control. GitHub Copilot proxies OpenAI
146
+ // models but rejects OpenAI's `service_tier` request field.
147
+ return provider === "fireworks" || provider === "github-copilot";
146
148
  }
147
149
 
148
150
  function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
149
151
  return (
150
- !hasDedicatedServiceTierControl(model.provider) && isOpenAIServiceTierApi(model.api) && isOpenAIModelId(model.id)
152
+ !excludesInferredOpenAIServiceTier(model.provider) &&
153
+ isOpenAIServiceTierApi(model.api) &&
154
+ isOpenAIModelId(model.id)
151
155
  );
152
156
  }
153
157
 
@@ -159,7 +163,8 @@ function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
159
163
  * `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
160
164
  * anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
161
165
  * Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family
162
- * too unless that provider owns a separate tier control such as Fireworks.
166
+ * too unless the provider owns a separate tier control (Fireworks) or rejects
167
+ * OpenAI's service-tier field (GitHub Copilot).
163
168
  */
164
169
  export function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined {
165
170
  const provider = model.provider;
@@ -75,7 +75,19 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
75
75
  case "thinking_delta": {
76
76
  projector ??= new LeakedThinkingProjector(out, event.partial);
77
77
  const block = event.partial.content[event.contentIndex];
78
- projector.thinking(event.delta, block?.type === "thinking" ? block.thinkingSignature : undefined);
78
+ projector.thinking(
79
+ event.contentIndex,
80
+ event.delta,
81
+ block?.type === "thinking" ? block.thinkingSignature : undefined,
82
+ );
83
+ break;
84
+ }
85
+ case "thinking_end": {
86
+ const block = event.partial.content[event.contentIndex];
87
+ projector?.thinkingEnd(
88
+ event.contentIndex,
89
+ block?.type === "thinking" ? block.thinkingSignature : undefined,
90
+ );
79
91
  break;
80
92
  }
81
93
  case "image_end":
@@ -108,8 +120,9 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
108
120
  out.push({ type: "error", reason: event.reason, error: { ...event.error, content } });
109
121
  return;
110
122
  }
111
- // text_start/text_end/thinking_start/thinking_end are ignored: the
112
- // projector owns block boundaries (matches wrapInbandToolStream).
123
+ // text_start/text_end/thinking_start are ignored: the projector owns
124
+ // block boundaries (matches wrapInbandToolStream). thinking_end is
125
+ // handled to capture the signature Anthropic delivers at block close.
113
126
  }
114
127
  }
115
128
  // Inner ended via end(result) without a terminal event.
@@ -144,6 +157,10 @@ class LeakedThinkingProjector {
144
157
  #lastTextSignature: string | undefined;
145
158
  /** Forwarded native tool calls, keyed by the inner stream's `contentIndex`. */
146
159
  #toolBlocks = new Map<number, { index: number; block: StreamingToolCall }>();
160
+ /** Projected native thinking blocks, keyed by the inner stream's `contentIndex`. */
161
+ #thinkingBlocks = new Map<number, number>();
162
+ /** Native thinking blocks whose projected `thinking_end` awaits the source end event. */
163
+ #pendingThinkingEnds = new Set<number>();
147
164
 
148
165
  constructor(out: AssistantMessageEventStream, seed: AssistantMessage) {
149
166
  this.#out = out;
@@ -158,15 +175,37 @@ class LeakedThinkingProjector {
158
175
  this.#apply(this.#healer.feedEvents(delta), this.#lastTextSignature);
159
176
  }
160
177
 
161
- /** Forward a native thinking delta, preserving its signature. */
162
- thinking(delta: string, signature: string | undefined): void {
163
- const index = this.#openThinking();
178
+ /** Forward a native thinking delta, preserving its source block identity and signature. */
179
+ thinking(srcIndex: number, delta: string, signature: string | undefined): void {
180
+ let index = this.#thinkingBlocks.get(srcIndex);
181
+ if (index === undefined) {
182
+ if (this.#thinking && this.#pendingThinkingEnds.has(this.#thinking.index)) this.#closeThinking();
183
+ index = this.#openThinking();
184
+ this.#thinkingBlocks.set(srcIndex, index);
185
+ this.#pendingThinkingEnds.add(index);
186
+ }
164
187
  const block = this.#partial.content[index] as ThinkingContent;
165
188
  block.thinking += delta;
166
189
  if (signature !== undefined) block.thinkingSignature = signature;
167
190
  this.#out.push({ type: "thinking_delta", contentIndex: index, delta, partial: this.#partial });
168
191
  }
169
192
 
193
+ /**
194
+ * Finalize a native thinking block by source identity. Its projected end is
195
+ * deferred until this event so stream consumers observe the completed
196
+ * signature before the block closes, even when later blocks started first.
197
+ */
198
+ thinkingEnd(srcIndex: number, signature: string | undefined): void {
199
+ const index = this.#thinkingBlocks.get(srcIndex);
200
+ if (index === undefined) return;
201
+ if (signature !== undefined) {
202
+ (this.#partial.content[index] as ThinkingContent).thinkingSignature = signature;
203
+ }
204
+ if (!this.#pendingThinkingEnds.delete(index)) return;
205
+ if (this.#thinking?.index === index) this.#thinking = undefined;
206
+ this.#emitThinkingEnd(index);
207
+ }
208
+
170
209
  /** Forward a completed native image after releasing held text. */
171
210
  image(content: ImageContent): void {
172
211
  this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
@@ -234,6 +273,10 @@ class LeakedThinkingProjector {
234
273
  * flush held-back fragments, close open blocks, and return the healed content.
235
274
  */
236
275
  finish(message: AssistantMessage): AssistantMessage["content"] {
276
+ for (const [srcIndex] of this.#thinkingBlocks) {
277
+ const block = message.content[srcIndex];
278
+ this.thinkingEnd(srcIndex, block?.type === "thinking" ? block.thinkingSignature : undefined);
279
+ }
237
280
  let fullText = "";
238
281
  let tailSignature: string | undefined;
239
282
  for (const block of message.content) {
@@ -304,13 +347,19 @@ class LeakedThinkingProjector {
304
347
 
305
348
  #closeThinking(): void {
306
349
  if (!this.#thinking) return;
307
- const block = this.#partial.content[this.#thinking.index] as ThinkingContent;
350
+ const index = this.#thinking.index;
351
+ this.#thinking = undefined;
352
+ if (this.#pendingThinkingEnds.has(index)) return;
353
+ this.#emitThinkingEnd(index);
354
+ }
355
+
356
+ #emitThinkingEnd(index: number): void {
357
+ const block = this.#partial.content[index] as ThinkingContent;
308
358
  this.#out.push({
309
359
  type: "thinking_end",
310
- contentIndex: this.#thinking.index,
360
+ contentIndex: index,
311
361
  content: block.thinking,
312
362
  partial: this.#partial,
313
363
  });
314
- this.#thinking = undefined;
315
364
  }
316
365
  }