@oh-my-pi/pi-ai 17.0.1 → 17.0.3

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,9 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.3] - 2026-07-17
6
+
5
7
  ### Fixed
6
8
 
9
+ - Replaced the opaque `h2 is not supported` failure on the Cursor run transport with an actionable error naming the ALPN-stripping proxy as the cause and pointing at the `providers.cursor.baseUrl` HTTP/2 bridge workaround. The run RPC is HTTP/2-only, so behind a TLS-intercepting proxy that strips ALPN (e.g. Zscaler) bun cannot negotiate `h2` and the completion cannot proceed ([#5828](https://github.com/can1357/oh-my-pi/issues/5828)).
10
+ - Restored the `createAssistantMessageEventStream()` root export used by legacy provider extensions ([#5879](https://github.com/can1357/oh-my-pi/issues/5879)).
11
+ - Fixed parallel Responses tool-result images interleaving synthetic user messages before all pending outputs, preventing strict OpenRouter/Moonshot backends from rejecting follow-up requests. ([#5850](https://github.com/can1357/oh-my-pi/issues/5850))
12
+ - Fixed Kimi Code K3 requests to send native named efforts (`low`, `high`, `max`) and use adaptive effort rather than generic token budgets on explicit Anthropic transport overrides ([#5893](https://github.com/can1357/oh-my-pi/issues/5893)).
7
13
  - Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs
14
+ - Fixed Anthropic usage reports treating the organization response header as the account identity, which caused the 5h/7d status-line segment to disappear for OAuth credentials without stored organization metadata. ([#5698](https://github.com/can1357/oh-my-pi/issues/5698))
15
+
16
+ ## [17.0.2] - 2026-07-17
17
+
18
+ ### Fixed
19
+
20
+ - Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs.
21
+ - Fixed auth-broker snapshot validation rejecting API keys stored via the `/login` flow, restoring support for gateway/broker setups serving login-sourced keys on custom hosts.
22
+ - Fixed an issue where literal reasoning tags (e.g., `<think>`) inside Markdown code blocks or inline code were incorrectly treated as reasoning boundaries, which corrupted the rendered Markdown.
23
+ - Classified HTTP 402 and "balance exhausted" quota responses as persistent usage limits, enabling automatic rotation of multi-account requests to a sibling credential.
24
+ - Fixed `kimi-code` Anthropic-format requests ignoring custom provider base URLs.
25
+ - Fixed an issue where GPT-5.6 Codex Responses-Lite requests failed with an HTTP 400 error due to invalid `tool_choice` parameters after tools were rewritten, by automatically downgrading forced hosted choices to `tool_choice: "auto"` while preserving explicit tool-use constraints.
26
+ - Fixed Cursor streams prematurely reporting success before late CONNECT or gRPC terminal failures were observed, and resolved issues rejecting transport ends without a `turnEnded` signal.
8
27
 
9
28
  ## [17.0.1] - 2026-07-16
10
29
 
@@ -29,6 +29,7 @@ export declare const remoteOauthCredentialSchema: import("arktype/internal/varia
29
29
  export declare const apiKeyCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
30
30
  type: "api_key";
31
31
  key: string;
32
+ source?: "login" | undefined;
32
33
  }, {}>;
33
34
  /** Discriminated union accepted on POST /v1/credential (writes). */
34
35
  export declare const writableAuthCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -46,6 +47,7 @@ export declare const writableAuthCredentialSchema: import("arktype/internal/vari
46
47
  } | {
47
48
  type: "api_key";
48
49
  key: string;
50
+ source?: "login" | undefined;
49
51
  }, {}>;
50
52
  /** Discriminated union returned in snapshots (refresh is sentinel for OAuth). */
51
53
  export declare const snapshotCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -63,6 +65,7 @@ export declare const snapshotCredentialSchema: import("arktype/internal/variants
63
65
  } | {
64
66
  type: "api_key";
65
67
  key: string;
68
+ source?: "login" | undefined;
66
69
  }, {}>;
67
70
  export declare const credentialSnapshotEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
68
71
  id: number;
@@ -82,6 +85,7 @@ export declare const credentialSnapshotEntrySchema: import("arktype/internal/var
82
85
  } | {
83
86
  type: "api_key";
84
87
  key: string;
88
+ source?: "login" | undefined;
85
89
  };
86
90
  identityKey: string | null;
87
91
  }, {}>;
@@ -109,6 +113,7 @@ export declare const snapshotEntrySchema: import("arktype/internal/variants/obje
109
113
  } | {
110
114
  type: "api_key";
111
115
  key: string;
116
+ source?: "login" | undefined;
112
117
  };
113
118
  identityKey: string | null;
114
119
  rotatesInMs: number | null;
@@ -153,6 +158,7 @@ export declare const snapshotResponseSchema: import("arktype/internal/variants/o
153
158
  } | {
154
159
  type: "api_key";
155
160
  key: string;
161
+ source?: "login" | undefined;
156
162
  };
157
163
  identityKey: string | null;
158
164
  rotatesInMs: number | null;
@@ -193,6 +199,7 @@ export declare const snapshotStreamSnapshotEventSchema: import("arktype/internal
193
199
  } | {
194
200
  type: "api_key";
195
201
  key: string;
202
+ source?: "login" | undefined;
196
203
  };
197
204
  identityKey: string | null;
198
205
  rotatesInMs: number | null;
@@ -234,6 +241,7 @@ export declare const snapshotStreamEntryEventSchema: import("arktype/internal/va
234
241
  } | {
235
242
  type: "api_key";
236
243
  key: string;
244
+ source?: "login" | undefined;
237
245
  };
238
246
  identityKey: string | null;
239
247
  rotatesInMs: number | null;
@@ -287,6 +295,7 @@ export declare const snapshotStreamEventSchema: import("arktype/internal/variant
287
295
  } | {
288
296
  type: "api_key";
289
297
  key: string;
298
+ source?: "login" | undefined;
290
299
  };
291
300
  identityKey: string | null;
292
301
  rotatesInMs: number | null;
@@ -326,6 +335,7 @@ export declare const snapshotStreamEventSchema: import("arktype/internal/variant
326
335
  } | {
327
336
  type: "api_key";
328
337
  key: string;
338
+ source?: "login" | undefined;
329
339
  };
330
340
  identityKey: string | null;
331
341
  rotatesInMs: number | null;
@@ -427,6 +437,7 @@ export declare const credentialRefreshResponseSchema: import("arktype/internal/v
427
437
  } | {
428
438
  type: "api_key";
429
439
  key: string;
440
+ source?: "login" | undefined;
430
441
  };
431
442
  identityKey: string | null;
432
443
  };
@@ -469,6 +480,7 @@ export declare const credentialUploadRequestSchema: import("arktype/internal/var
469
480
  } | {
470
481
  type: "api_key";
471
482
  key: string;
483
+ source?: "login" | undefined;
472
484
  };
473
485
  }, {}>;
474
486
  export declare const credentialUploadResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -490,6 +502,7 @@ export declare const credentialUploadResponseSchema: import("arktype/internal/va
490
502
  } | {
491
503
  type: "api_key";
492
504
  key: string;
505
+ source?: "login" | undefined;
493
506
  };
494
507
  identityKey: string | null;
495
508
  }[];
@@ -20,6 +20,9 @@ export declare function calculateRateLimitBackoffMs(reason: RateLimitReason): nu
20
20
  /**
21
21
  * HTTP status codes that, absent richer body classification, represent an
22
22
  * account-local usage cap rather than a bad credential or a transient blip.
23
+ * HTTP 402 Payment Required is categorically an account-billing cap (xAI
24
+ * Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
25
+ * OpenRouter credit exhaustion) — never a transient blip or bad credential.
23
26
  * Always combine with {@link isUsageLimitOutcome} when a message is available
24
27
  * — a 429 carrying transient rate-limit wording is NOT a usage cap.
25
28
  */
@@ -31,7 +34,7 @@ export declare function isUsageLimitStatus(status: number | undefined): boolean;
31
34
  * 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
32
35
  * Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
33
36
  * `insufficient_quota`, …) → rotate.
34
- * 2. Status is not 429 → backoff (caller's domain).
37
+ * 2. Status is not a usage-limit status (429/402) → backoff (caller's domain).
35
38
  * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
36
39
  * empty JSON, HTTP framing only) → rotate conservatively: the server
37
40
  * gave us nothing else to go on.
@@ -44,10 +47,11 @@ export declare function isUsageLimitStatus(status: number | undefined): boolean;
44
47
  */
45
48
  export declare function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean;
46
49
  /**
47
- * A 429 body is opaque when it carries no signal beyond the status itself —
48
- * empty, whitespace-only, the status digits with HTTP/JSON framing, or
49
- * generic punctuation. Anything else (retry hints, capacity wording, error
50
- * descriptions) is informative enough to defer to the classifier.
50
+ * A usage-limit status body is opaque when it carries no signal beyond the
51
+ * status itself — empty, whitespace-only, the status digits with HTTP/JSON
52
+ * framing, or generic punctuation. Anything else (retry hints, capacity
53
+ * wording, error descriptions) is informative enough to defer to the
54
+ * classifier.
51
55
  */
52
56
  export declare function isOpaqueStatusBody(message: string): boolean;
53
57
  /**
@@ -13,6 +13,20 @@ export interface CursorOptions extends StreamOptions {
13
13
  execHandlers?: CursorExecHandlers;
14
14
  onToolResult?: CursorToolResultHandler;
15
15
  }
16
+ /**
17
+ * Maps an opaque HTTP/2 negotiation failure into an actionable error.
18
+ *
19
+ * bun only opens an HTTP/2 session when TLS-ALPN negotiates `h2`. Behind a
20
+ * TLS-intercepting proxy that strips ALPN (e.g. Zscaler), the handshake yields
21
+ * no `h2` protocol and bun throws `ERR_HTTP2_ERROR: h2 is not supported`. The
22
+ * Cursor run RPC is HTTP/2-only (the ALB rejects HTTP/1.1 with 464), so there
23
+ * is no h1 fallback the way model discovery has one — the run simply cannot
24
+ * proceed. Replace the opaque message with one that names the cause and points
25
+ * at the `providers.cursor.baseUrl` workaround.
26
+ *
27
+ * Non-ALPN errors pass through untouched.
28
+ */
29
+ export declare function mapH2TransportError(error: unknown, baseUrl: string): unknown;
16
30
  export declare const streamCursor: StreamFunction<"cursor-agent">;
17
31
  export type ToolCallState = ToolCall & {
18
32
  [kStreamingBlockIndex]: number;
@@ -29,6 +43,8 @@ export interface BlockState {
29
43
  [kStreamingBlockIndex]: number;
30
44
  }) | null;
31
45
  currentToolCall: ToolCallState | null;
46
+ /** MCP call IDs executed through Cursor's exec channel before their stream block arrives. */
47
+ resolvedMcpToolCallIds: Set<string>;
32
48
  firstTokenTime: number | undefined;
33
49
  setTextBlock: (b: (TextContent & {
34
50
  [kStreamingBlockIndex]: number;
@@ -5,15 +5,15 @@
5
5
  * - OpenAI: https://api.kimi.com/coding/v1/chat/completions
6
6
  * - Anthropic: https://api.kimi.com/coding/v1/messages
7
7
  *
8
- * The Anthropic API is generally more stable and recommended.
9
- * Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
8
+ * Each discovered model selects its server-declared protocol; legacy models
9
+ * without protocol metadata retain the Anthropic-compatible default.
10
10
  */
11
11
  import type { Api, Context, Model } from "../types.js";
12
12
  import type { AssistantMessageEventStream } from "../utils/event-stream.js";
13
13
  import { type OpenAIAnthropicApiFormat, type OpenAIAnthropicShimOptions } from "./openai-anthropic-shim.js";
14
14
  export type KimiApiFormat = OpenAIAnthropicApiFormat;
15
15
  export interface KimiOptions extends OpenAIAnthropicShimOptions {
16
- /** API format: "openai" or "anthropic". Default: "anthropic" */
16
+ /** Explicit API format override. Defaults to the model's discovered protocol. */
17
17
  format?: KimiApiFormat;
18
18
  }
19
19
  /**
@@ -7,7 +7,7 @@
7
7
  * format, optional extra headers); the streaming/forwarding plumbing lives
8
8
  * here once.
9
9
  */
10
- import type { Context, Model, SimpleStreamOptions } from "../types.js";
10
+ import type { Context, Model, SimpleStreamOptions, ThinkingControlMode } from "../types.js";
11
11
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
12
12
  export type OpenAIAnthropicApiFormat = "openai" | "anthropic";
13
13
  export interface OpenAIAnthropicShimOptions extends SimpleStreamOptions {
@@ -21,6 +21,8 @@ export interface OpenAIAnthropicShimConfig {
21
21
  openaiBaseUrl?: string;
22
22
  /** Default API format when caller does not specify one. */
23
23
  defaultFormat: OpenAIAnthropicApiFormat;
24
+ /** Thinking transport used when this provider's Anthropic endpoint differs from generic budget semantics. */
25
+ anthropicThinkingMode?: ThinkingControlMode;
24
26
  /** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */
25
27
  extraHeaders?: () => Record<string, string>;
26
28
  }
@@ -77,6 +77,7 @@ export declare function resolveCodexResponsesLite(model: Model<"openai-codex-res
77
77
  export interface CodexLiteShapedBody {
78
78
  instructions?: unknown;
79
79
  tools?: unknown;
80
+ tool_choice?: unknown;
80
81
  input?: unknown;
81
82
  parallel_tool_calls?: unknown;
82
83
  }
@@ -85,9 +86,14 @@ export interface CodexLiteShapedBody {
85
86
  * `build_responses_request` with `use_responses_lite`): strips pinned image
86
87
  * detail, forces parallel tool calling off, moves tools into a leading
87
88
  * `additional_tools` developer item and the base instructions into a
88
- * developer message, then omits top-level `instructions`/`tools`. Shared by
89
- * normal turns and both remote-compaction paths — codex-rs routes
90
- * `/responses/compact` through the same builder.
89
+ * developer message, then omits top-level `instructions`/`tools`. Because the
90
+ * rewrite removes top-level `tools`, a forced hosted-tool choice (e.g.
91
+ * `{ type: "web_search" }`) would leave the backend unable to validate the
92
+ * choice against a tools collection and it rejects the request with HTTP 400
93
+ * (#5771). Such choices must fall back to `"auto"`; explicit string constraints
94
+ * such as `"none"` and `"required"` remain valid. Shared by normal turns and
95
+ * both remote-compaction paths — codex-rs routes `/responses/compact` through
96
+ * the same builder.
91
97
  */
92
98
  export declare function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void;
93
99
  export declare function transformRequestBody(body: RequestBody, model: Model<"openai-codex-responses">, options?: CodexRequestOptions, prompt?: {
@@ -200,6 +200,7 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
200
200
  repetition_penalty?: number;
201
201
  thinking?: {
202
202
  type: "enabled" | "disabled";
203
+ effort?: string;
203
204
  keep?: "all";
204
205
  };
205
206
  enable_thinking?: boolean;
@@ -297,12 +298,14 @@ export declare function applyChatCompletionsReasoningParams(params: OpenAIComple
297
298
  }) | undefined): void;
298
299
  export declare function disableChatCompletionsReasoningForDialect(params: OpenAICompletionsParams, compat: ResolvedOpenAICompat): void;
299
300
  /**
300
- * Output-token clamp for the Z.AI/GLM-5.2 reasoning dialect: these hosts accept
301
- * the full model window on reasoning turns, so clamp to the model cap. Returns
302
- * `undefined` for every other model, leaving {@link resolveOpenAIOutputTokenParam}
303
- * on its default `OPENAI_MAX_OUTPUT_TOKENS` clamp.
301
+ * Provider-specific Chat Completions output clamp.
302
+ *
303
+ * Most OpenAI-compatible endpoints retain the conservative 64k ceiling from
304
+ * {@link resolveOpenAIOutputTokenParam}. Z.AI/GLM-5.2 reasoning and native
305
+ * Moonshot K3 explicitly accept their full advertised model caps, so those
306
+ * routes clamp to `model.maxTokens` instead.
304
307
  */
305
- export declare function resolveZaiReasoningOutputClamp(model: Model<"openai-completions">, compat: ResolvedOpenAICompat): number | undefined;
308
+ export declare function resolveOpenAICompletionsOutputClamp(model: Model<"openai-completions">, compat: ResolvedOpenAICompat): number | undefined;
306
309
  /**
307
310
  * Enable `tool_stream` for Z.AI/GLM-5.2 reasoning models when tools are present
308
311
  * (GLM-5.2 streams tool-call arguments incrementally and needs the flag to do so).
@@ -380,6 +383,7 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
380
383
  }
381
384
  export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
382
385
  export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>, preserveMessageIds?: boolean, supportsCustomToolCalls?: boolean, customToolWireNameMap?: ReadonlyMap<string, string>): ResponseInput;
386
+ /** Appends one tool result while keeping consecutive outputs ahead of its synthetic image messages. */
383
387
  export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>, supportsCustomToolCalls?: boolean): void;
384
388
  /**
385
389
  * Per-block accumulation helpers shared by the two Responses decode loops —
@@ -1,11 +1,7 @@
1
1
  import type { Api, AssistantMessage, Message, Model } from "../types.js";
2
- /**
3
- * Normalize tool call ID for cross-provider compatibility.
4
- * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
5
- * Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars).
6
- *
7
- * For aborted/errored turns, this function:
8
- * - Preserves tool call structure (unlike converting to text summaries)
9
- * - Injects synthetic "aborted" tool results
10
- */
2
+ export declare function redactSensitiveCredentials(text: string): string;
3
+ export declare function redactSensitiveInObject(val: unknown): {
4
+ result: unknown;
5
+ changed: boolean;
6
+ };
11
7
  export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string, targetCompat?: Model<TApi>["compat"]): Message[];
@@ -376,7 +376,7 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
376
376
  toolChoice?: ToolChoice;
377
377
  /** OpenAI service tier for processing priority/cost control. Ignored by non-OpenAI providers. */
378
378
  serviceTier?: ServiceTier;
379
- /** API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic") */
379
+ /** Explicit Kimi Code API format override; omitted uses live per-model protocol metadata. */
380
380
  kimiApiFormat?: "openai" | "anthropic";
381
381
  /** API format for Synthetic provider: "openai" or "anthropic" (default: "openai") */
382
382
  syntheticApiFormat?: "openai" | "anthropic";
@@ -35,3 +35,5 @@ export declare class AssistantMessageEventStream extends EventStream<AssistantMe
35
35
  push(event: AssistantMessageEvent): void;
36
36
  end(result?: AssistantMessage): void;
37
37
  }
38
+ /** Create an assistant-message event stream for legacy extension providers. */
39
+ export declare function createAssistantMessageEventStream(): AssistantMessageEventStream;
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.1",
4
+ "version": "17.0.3",
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.1",
42
- "@oh-my-pi/pi-utils": "17.0.1",
43
- "@oh-my-pi/pi-wire": "17.0.1",
41
+ "@oh-my-pi/pi-catalog": "17.0.3",
42
+ "@oh-my-pi/pi-utils": "17.0.3",
43
+ "@oh-my-pi/pi-wire": "17.0.3",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -55,6 +55,7 @@ export const apiKeyCredentialSchema = type({
55
55
  "+": "reject",
56
56
  type: "'api_key'",
57
57
  key: type("string").atLeastLength(1),
58
+ "source?": "'login'",
58
59
  });
59
60
 
60
61
  /** Discriminated union accepted on POST /v1/credential (writes). */
@@ -32,6 +32,16 @@ export class ThinkingInbandScanner implements InbandScanner {
32
32
  #thinking = "";
33
33
  /** Fence-aware close-matcher while inside a ` ```thinking ` block; undefined otherwise. */
34
34
  #fenced: FencedThinkingScanner | undefined;
35
+ /** Backtick count that opened the Markdown code span/fence we are inside; 0 when not in code. */
36
+ #codeTicks = 0;
37
+ /** True when {@link #codeTicks} opened a fenced block (closes on a fence line), not an inline span. */
38
+ #codeFenced = false;
39
+ /**
40
+ * Leading-space count on the current output line, or -1 once a non-space
41
+ * character has appeared. Starts at 0 (line start) so a fence opening the
42
+ * stream — or one indented ≤3 spaces, as CommonMark allows — is recognized.
43
+ */
44
+ #lineIndent = 0;
35
45
 
36
46
  feed(text: string): InbandScanEvent[] {
37
47
  if (text.length === 0) return [];
@@ -86,25 +96,93 @@ export class ThinkingInbandScanner implements InbandScanner {
86
96
  this.#closeTag = "";
87
97
  continue;
88
98
  }
99
+ if (this.#codeTicks > 0) {
100
+ if (this.#emitCode(final, events)) continue;
101
+ break;
102
+ }
89
103
 
90
- const tag = findEarliestOpen(this.#buffer);
91
- if (!tag) {
92
- const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, OPENS);
93
- const emit = this.#buffer.slice(0, this.#buffer.length - hold);
94
- if (emit.length > 0) events.push({ type: "text", text: emit });
95
- this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
104
+ const hit = scanVisible(this.#buffer, final);
105
+ if (hit.kind === "none") {
106
+ this.#emitText(this.#buffer, events);
107
+ this.#buffer = "";
108
+ break;
109
+ }
110
+ if (hit.index > 0) this.#emitText(this.#buffer.slice(0, hit.index), events);
111
+ if (hit.kind === "hold") {
112
+ this.#buffer = this.#buffer.slice(hit.index);
96
113
  break;
97
114
  }
98
- if (tag.index > 0) events.push({ type: "text", text: this.#buffer.slice(0, tag.index) });
99
- this.#buffer = this.#buffer.slice(tag.index + tag.open.length);
100
- this.#closeTag = tag.close;
115
+ if (hit.kind === "code") {
116
+ const fenced = hit.ticks >= 3 && this.#lineIndent >= 0 && this.#lineIndent <= 3;
117
+ this.#emitText(this.#buffer.slice(hit.index, hit.index + hit.ticks), events);
118
+ this.#buffer = this.#buffer.slice(hit.index + hit.ticks);
119
+ this.#codeTicks = hit.ticks;
120
+ this.#codeFenced = fenced;
121
+ continue;
122
+ }
123
+ this.#buffer = this.#buffer.slice(hit.index + hit.tag.open.length);
124
+ this.#closeTag = hit.tag.close;
101
125
  this.#thinking = "";
102
- if (tag.fenced) this.#fenced = new FencedThinkingScanner();
126
+ if (hit.tag.fenced) this.#fenced = new FencedThinkingScanner();
103
127
  events.push({ type: "thinkingStart" });
104
128
  }
105
129
  return events;
106
130
  }
107
131
 
132
+ /**
133
+ * Emit buffered content while inside a Markdown code region, suppressing
134
+ * reasoning-tag detection. A fenced block closes only on a fence line (a line
135
+ * of backticks ≥ the opener); an inline span closes on the first backtick run
136
+ * of exactly the opener length. Returns true when the region closed and the
137
+ * loop should continue, false when it held back and should break.
138
+ */
139
+ #emitCode(final: boolean, events: InbandScanEvent[]): boolean {
140
+ if (this.#codeFenced) {
141
+ const end = findFenceCloseEnd(this.#buffer, this.#codeTicks, final);
142
+ if (end !== -1) {
143
+ this.#emitText(this.#buffer.slice(0, end), events);
144
+ this.#buffer = this.#buffer.slice(end);
145
+ this.#codeTicks = 0;
146
+ this.#codeFenced = false;
147
+ return true;
148
+ }
149
+ if (final) {
150
+ this.#emitText(this.#buffer, events);
151
+ this.#buffer = "";
152
+ this.#codeTicks = 0;
153
+ this.#codeFenced = false;
154
+ return false;
155
+ }
156
+ // Stream committed lines; hold only the last (possibly partial) fence line.
157
+ const lastNl = this.#buffer.lastIndexOf("\n");
158
+ if (lastNl !== -1) {
159
+ this.#emitText(this.#buffer.slice(0, lastNl + 1), events);
160
+ this.#buffer = this.#buffer.slice(lastNl + 1);
161
+ }
162
+ return false;
163
+ }
164
+ const close = findBacktickRun(this.#buffer, 0, this.#codeTicks);
165
+ if (close !== -1 && (final || close + this.#codeTicks < this.#buffer.length)) {
166
+ this.#emitText(this.#buffer.slice(0, close + this.#codeTicks), events);
167
+ this.#buffer = this.#buffer.slice(close + this.#codeTicks);
168
+ this.#codeTicks = 0;
169
+ return true;
170
+ }
171
+ // No committed close yet: emit text, holding a trailing backtick run that
172
+ // may still grow into — or past — the closing delimiter.
173
+ const hold = final ? 0 : trailingBacktickRun(this.#buffer);
174
+ this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
175
+ this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
176
+ if (final) this.#codeTicks = 0;
177
+ return false;
178
+ }
179
+
180
+ #emitText(text: string, events: InbandScanEvent[]): void {
181
+ if (text.length === 0) return;
182
+ events.push({ type: "text", text });
183
+ this.#lineIndent = trailingLineIndent(text, this.#lineIndent);
184
+ }
185
+
108
186
  #emitThinking(delta: string, events: InbandScanEvent[]): void {
109
187
  if (delta.length === 0) return;
110
188
  this.#thinking += delta;
@@ -112,11 +190,103 @@ export class ThinkingInbandScanner implements InbandScanner {
112
190
  }
113
191
  }
114
192
 
115
- function findEarliestOpen(buffer: string): (Tag & { index: number }) | undefined {
116
- let best: (Tag & { index: number }) | undefined;
117
- for (const tag of TAGS) {
118
- const index = buffer.indexOf(tag.open);
119
- if (index !== -1 && (!best || index < best.index)) best = { ...tag, index };
193
+ /** Outcome of scanning idle visible text for the next reasoning-tag or code-span boundary. */
194
+ type VisibleHit =
195
+ | { readonly kind: "tag"; readonly index: number; readonly tag: Tag }
196
+ | { readonly kind: "code"; readonly index: number; readonly ticks: number }
197
+ | { readonly kind: "hold"; readonly index: number }
198
+ | { readonly kind: "none" };
199
+
200
+ /**
201
+ * Walk idle visible text for the earliest boundary: a leaked reasoning-tag open,
202
+ * a Markdown code-span/fence opener (a backtick run), or — when more chunks may
203
+ * follow — a held partial delimiter at the buffer tail.
204
+ *
205
+ * Reasoning tags win at any position so the gemini ` ```thinking ` fence is
206
+ * healed instead of being read as a code fence. Backtick runs enter code mode so
207
+ * a literal `<think>` inside inline code or a fenced block stays visible text.
208
+ */
209
+ function scanVisible(buffer: string, final: boolean): VisibleHit {
210
+ for (let i = 0; i < buffer.length; i++) {
211
+ const tag = TAGS.find(candidate => buffer.startsWith(candidate.open, i));
212
+ if (tag) return { kind: "tag", index: i, tag };
213
+ if (!final) {
214
+ const rest = buffer.slice(i);
215
+ if (OPENS.some(open => open.length > rest.length && open.startsWith(rest))) {
216
+ return { kind: "hold", index: i };
217
+ }
218
+ }
219
+ if (buffer[i] === "`") {
220
+ const ticks = backtickRun(buffer, i);
221
+ if (!final && i + ticks === buffer.length) return { kind: "hold", index: i };
222
+ return { kind: "code", index: i, ticks };
223
+ }
224
+ }
225
+ return { kind: "none" };
226
+ }
227
+
228
+ /** Length of the maximal backtick run beginning at `from`. */
229
+ function backtickRun(buffer: string, from: number): number {
230
+ let end = from;
231
+ while (end < buffer.length && buffer[end] === "`") end++;
232
+ return end - from;
233
+ }
234
+
235
+ /** Index of the first maximal backtick run of exactly `ticks` at/after `from`, else -1. */
236
+ function findBacktickRun(buffer: string, from: number, ticks: number): number {
237
+ for (let i = buffer.indexOf("`", from); i !== -1; i = buffer.indexOf("`", i)) {
238
+ const run = backtickRun(buffer, i);
239
+ if (run === ticks) return i;
240
+ i += run;
120
241
  }
121
- return best;
242
+ return -1;
243
+ }
244
+
245
+ /** Length of a backtick run that ends at the buffer tail; 0 when the tail is not a backtick. */
246
+ function trailingBacktickRun(buffer: string): number {
247
+ let start = buffer.length;
248
+ while (start > 0 && buffer[start - 1] === "`") start--;
249
+ return buffer.length - start;
250
+ }
251
+
252
+ /**
253
+ * Leading-space count of the line at the tail of `text`, continuing from the
254
+ * prior line's `indent` state (see {@link ThinkingInbandScanner.#lineIndent}).
255
+ * Returns -1 once any non-space character has appeared on the current line.
256
+ */
257
+ function trailingLineIndent(text: string, prior: number): number {
258
+ const lastNl = text.lastIndexOf("\n");
259
+ let indent = lastNl === -1 ? prior : 0;
260
+ for (let i = lastNl + 1; i < text.length; i++) {
261
+ if (indent === -1) break;
262
+ indent = text[i] === " " ? indent + 1 : -1;
263
+ }
264
+ return indent;
265
+ }
266
+
267
+ /**
268
+ * Index just past the first closing fence line for a fenced block opened with
269
+ * `ticks` backticks, or -1 when none is committed yet. A closing fence is a whole
270
+ * line whose trimmed content is only backticks, at least `ticks` of them. A line
271
+ * without a terminating newline is committed only when `final` (no more input can
272
+ * extend it into a non-fence line).
273
+ */
274
+ function findFenceCloseEnd(buffer: string, ticks: number, final: boolean): number {
275
+ for (let start = 0; start <= buffer.length; ) {
276
+ const nl = buffer.indexOf("\n", start);
277
+ const terminated = nl !== -1;
278
+ const line = buffer.slice(start, terminated ? nl : buffer.length).trim();
279
+ if (line.length >= ticks && isAllBackticks(line) && (terminated || final)) {
280
+ return terminated ? nl + 1 : buffer.length;
281
+ }
282
+ if (!terminated) break;
283
+ start = nl + 1;
284
+ }
285
+ return -1;
286
+ }
287
+
288
+ /** True when `text` is non-empty and every character is a backtick. */
289
+ function isAllBackticks(text: string): boolean {
290
+ for (let i = 0; i < text.length; i++) if (text[i] !== "`") return false;
291
+ return text.length > 0;
122
292
  }
@@ -7,7 +7,7 @@ import {
7
7
  ProviderHttpError,
8
8
  STREAM_ENVELOPE_ERROR_PREFIX,
9
9
  } from "./classes";
10
- import { isOpaqueStatusBody, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
10
+ import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
11
11
 
12
12
  export const Flag = {
13
13
  Class: 0x1000,
@@ -318,7 +318,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
318
318
  const cleanMessage = errorMessage;
319
319
  const isOpaque = isOpaqueStatusBody(cleanMessage);
320
320
 
321
- const isLimitStatus = statusClean === 429;
321
+ const isLimitStatus = isUsageLimitStatus(statusClean);
322
322
  if (
323
323
  matchesUsageLimitText(cleanMessage) ||
324
324
  (isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED"))