@oh-my-pi/pi-ai 17.0.0 → 17.0.2

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 (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/auth-broker/wire-schemas.d.ts +13 -0
  3. package/dist/types/auth-storage.d.ts +2 -0
  4. package/dist/types/error/auth-classify.d.ts +2 -0
  5. package/dist/types/error/rate-limit.d.ts +9 -5
  6. package/dist/types/providers/cursor.d.ts +2 -0
  7. package/dist/types/providers/openai-codex/request-transformer.d.ts +9 -3
  8. package/dist/types/providers/openai-shared.d.ts +12 -6
  9. package/dist/types/providers/transform-messages.d.ts +5 -9
  10. package/dist/types/types.d.ts +6 -1
  11. package/dist/types/utils/empty-completion-retry.d.ts +3 -3
  12. package/dist/types/utils/schema/normalize.d.ts +3 -1
  13. package/package.json +4 -4
  14. package/src/auth-broker/wire-schemas.ts +1 -0
  15. package/src/auth-retry.ts +2 -2
  16. package/src/auth-storage.ts +120 -79
  17. package/src/dialect/owned-stream.ts +11 -0
  18. package/src/dialect/thinking.ts +186 -16
  19. package/src/error/auth-classify.ts +13 -0
  20. package/src/error/flags.ts +2 -2
  21. package/src/error/rate-limit.ts +28 -9
  22. package/src/providers/__tests__/kimi-code-thinking.test.ts +62 -7
  23. package/src/providers/anthropic.ts +82 -25
  24. package/src/providers/cursor.ts +53 -30
  25. package/src/providers/gitlab-duo-workflow.ts +6 -2
  26. package/src/providers/kimi.ts +1 -4
  27. package/src/providers/openai-codex/request-transformer.ts +12 -3
  28. package/src/providers/openai-codex-responses.ts +22 -4
  29. package/src/providers/openai-completions.ts +28 -22
  30. package/src/providers/openai-responses.ts +5 -1
  31. package/src/providers/openai-shared.ts +66 -14
  32. package/src/providers/transform-messages.ts +171 -1
  33. package/src/stream.ts +2 -0
  34. package/src/types.ts +9 -1
  35. package/src/utils/empty-completion-retry.ts +6 -3
  36. package/src/utils/leaked-thinking-stream.ts +19 -1
  37. package/src/utils/proxy.ts +1 -1
  38. package/src/utils/schema/CONSTRAINTS.md +1 -0
  39. package/src/utils/schema/fields.ts +3 -0
  40. package/src/utils/schema/normalize.ts +94 -24
  41. package/src/utils.ts +4 -1
@@ -797,6 +797,8 @@ export interface InvalidateCredentialMatchingOptions {
797
797
 
798
798
  /** Options for refreshing one stored OAuth row through durable ownership. */
799
799
  export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
800
+ /** Stable row id when a provider has multiple OAuth credentials. */
801
+ credentialId?: number;
800
802
  observedCredential?: T;
801
803
  credentialFromRow: (credential: OAuthCredential) => T | undefined;
802
804
  forceRefresh?: boolean;
@@ -2007,17 +2009,32 @@ export class AuthStorage {
2007
2009
  }
2008
2010
 
2009
2011
  /**
2010
- * Persist a refreshed credential addressed by id, not a positional index.
2011
- * A concurrent disable can reorder/shrink the provider's row array while an
2012
- * async refresh is in flight, so a pre-await index is unsafe; resolving the
2013
- * row by id at write time lands the rotated token on the correct row. Returns
2014
- * the row's current index, or -1 when it was disabled/removed mid-refresh.
2012
+ * Persist a refreshed credential by id only while the row still matches this
2013
+ * process's snapshot. A peer rotation wins the CAS and is reloaded instead of
2014
+ * being overwritten after this process releases its refresh lease.
2015
+ *
2016
+ * Returns the row's current index, or -1 when it was disabled or removed.
2015
2017
  */
2016
2018
  #replaceCredentialById(provider: string, id: number, credential: AuthCredential): number {
2017
2019
  const entries = this.#getStoredCredentials(provider);
2018
2020
  const index = entries.findIndex(entry => entry.id === id);
2019
2021
  if (index === -1) return -1;
2020
- this.#store.updateAuthCredential(id, credential);
2022
+ const expected = serializeCredential(provider, entries[index]!.credential);
2023
+ if (
2024
+ expected &&
2025
+ this.#store.tryUpdateAuthCredentialIfMatches &&
2026
+ !this.#store.tryUpdateAuthCredentialIfMatches(id, expected.data, credential)
2027
+ ) {
2028
+ const latest = this.#store.listAuthCredentials(provider);
2029
+ this.#setStoredCredentials(
2030
+ provider,
2031
+ latest.map(row => ({ id: row.id, credential: row.credential })),
2032
+ );
2033
+ return latest.findIndex(row => row.id === id);
2034
+ }
2035
+ if (!expected || !this.#store.tryUpdateAuthCredentialIfMatches) {
2036
+ this.#store.updateAuthCredential(id, credential);
2037
+ }
2021
2038
  const updated = [...entries];
2022
2039
  updated[index] = { id, credential };
2023
2040
  this.#setStoredCredentials(provider, updated);
@@ -2150,7 +2167,11 @@ export class AuthStorage {
2150
2167
  provider,
2151
2168
  rows.map(row => ({ id: row.id, credential: row.credential })),
2152
2169
  );
2153
- const row = rows.find(entry => entry.credential.type === "oauth");
2170
+ const row = rows.find(
2171
+ entry =>
2172
+ entry.credential.type === "oauth" &&
2173
+ (options.credentialId === undefined || entry.id === options.credentialId),
2174
+ );
2154
2175
  if (row?.credential.type !== "oauth") {
2155
2176
  return { credential: undefined, refreshed: false, removed: false };
2156
2177
  }
@@ -2189,7 +2210,11 @@ export class AuthStorage {
2189
2210
  provider,
2190
2211
  rows.map(row => ({ id: row.id, credential: row.credential })),
2191
2212
  );
2192
- const row = rows.find(entry => entry.credential.type === "oauth");
2213
+ const row = rows.find(
2214
+ entry =>
2215
+ entry.credential.type === "oauth" &&
2216
+ (options.credentialId === undefined || entry.id === options.credentialId),
2217
+ );
2193
2218
  if (row?.credential.type !== "oauth") {
2194
2219
  return { credential: undefined, refreshed: false, removed: false };
2195
2220
  }
@@ -2266,7 +2291,7 @@ export class AuthStorage {
2266
2291
  return { credential: undefined, refreshed: false, removed: true };
2267
2292
  }
2268
2293
  await this.reload();
2269
- const latest = this.get(provider);
2294
+ const latest = this.#getStoredCredentials(provider).find(entry => entry.id === row.id)?.credential;
2270
2295
  return {
2271
2296
  credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
2272
2297
  refreshed: false,
@@ -2316,7 +2341,7 @@ export class AuthStorage {
2316
2341
  )
2317
2342
  ) {
2318
2343
  await this.reload();
2319
- const latest = this.get(provider);
2344
+ const latest = this.#getStoredCredentials(provider).find(entry => entry.id === row.id)?.credential;
2320
2345
  return {
2321
2346
  credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
2322
2347
  refreshed: false,
@@ -2808,37 +2833,27 @@ export class AuthStorage {
2808
2833
  return match?.id;
2809
2834
  }
2810
2835
 
2811
- #persistRefreshedUsageCredential(provider: Provider, previous: UsageCredential, next: UsageCredential): void {
2812
- const entries = this.#getStoredCredentials(provider);
2813
- // Same sentinel rule as #findStoredCredentialIdForUsageCredential above.
2814
- const previousRefresh =
2815
- previous.refreshToken && previous.refreshToken !== REMOTE_REFRESH_SENTINEL ? previous.refreshToken : undefined;
2816
- const index = entries.findIndex(entry => {
2817
- if (entry.credential.type !== "oauth") return false;
2818
- if (previousRefresh && entry.credential.refresh === previousRefresh) return true;
2819
- if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
2820
- return (
2821
- entry.credential.accountId === previous.accountId &&
2822
- entry.credential.email === previous.email &&
2823
- entry.credential.projectId === previous.projectId &&
2824
- entry.credential.orgId === previous.orgId
2825
- );
2826
- });
2827
- if (index === -1) return;
2828
- const existing = entries[index]!.credential;
2829
- if (existing.type !== "oauth") return;
2830
- this.#replaceCredentialAt(provider, index, {
2836
+ #persistRefreshedUsageCredential(
2837
+ provider: Provider,
2838
+ previous: UsageCredential,
2839
+ next: UsageCredential,
2840
+ credentialId = this.#findStoredCredentialIdForUsageCredential(provider, previous),
2841
+ ): void {
2842
+ if (credentialId === undefined) return;
2843
+ const entry = this.#getStoredCredentials(provider).find(candidate => candidate.id === credentialId);
2844
+ if (entry?.credential.type !== "oauth") return;
2845
+ this.#replaceCredentialById(provider, credentialId, {
2831
2846
  type: "oauth",
2832
- access: next.accessToken ?? existing.access,
2833
- refresh: next.refreshToken ?? existing.refresh,
2834
- expires: next.expiresAt ?? existing.expires,
2847
+ access: next.accessToken ?? entry.credential.access,
2848
+ refresh: next.refreshToken ?? entry.credential.refresh,
2849
+ expires: next.expiresAt ?? entry.credential.expires,
2835
2850
  accountId: next.accountId,
2836
2851
  projectId: next.projectId,
2837
2852
  email: next.email,
2838
2853
  enterpriseUrl: next.enterpriseUrl,
2839
2854
  apiEndpoint: next.apiEndpoint,
2840
- orgId: next.orgId ?? existing.orgId,
2841
- orgName: next.orgName ?? existing.orgName,
2855
+ orgId: next.orgId ?? entry.credential.orgId,
2856
+ orgName: next.orgName ?? entry.credential.orgName,
2842
2857
  });
2843
2858
  }
2844
2859
 
@@ -2878,7 +2893,12 @@ export class AuthStorage {
2878
2893
  timeoutSignal,
2879
2894
  );
2880
2895
  const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
2881
- this.#persistRefreshedUsageCredential(request.provider, request.credential, refreshedCredential);
2896
+ this.#persistRefreshedUsageCredential(
2897
+ request.provider,
2898
+ request.credential,
2899
+ refreshedCredential,
2900
+ refreshableCredentialId,
2901
+ );
2882
2902
  params = {
2883
2903
  ...request,
2884
2904
  credential: refreshedCredential,
@@ -2887,46 +2907,16 @@ export class AuthStorage {
2887
2907
  };
2888
2908
  } catch (error) {
2889
2909
  const errorMsg = String(error);
2890
- // Definitive failure (invalid_grant / 401 not from a network blip) means
2891
- // the refresh token itself is dead probing with the original credential
2892
- // will 401, the catch below will return null, and #fetchUsageCached's
2893
- // last-good fallback will surface yesterday's report indefinitely
2894
- // (including its already-elapsed `resetsAt`). CAS-disable the row and
2895
- // clear the cache so the credential drops out of the report instead of
2896
- // freezing in place until the user notices and re-logs in.
2897
- if (AIError.isDefinitiveOAuthFailure(errorMsg)) {
2898
- const credentialId = this.#findStoredCredentialIdForUsageCredential(
2899
- request.provider,
2900
- request.credential,
2901
- );
2902
- if (credentialId !== undefined) {
2903
- const entries = this.#getStoredCredentials(request.provider);
2904
- const index = entries.findIndex(entry => entry.id === credentialId);
2905
- if (index !== -1) {
2906
- const disabled = this.#tryDisableCredentialAtIfMatches(
2907
- request.provider,
2908
- index,
2909
- refreshableCredential,
2910
- `oauth refresh failed during usage probe: ${errorMsg}`,
2911
- );
2912
- if (disabled) {
2913
- this.#usageLogger?.warn(
2914
- "Usage credential refresh failed definitively; credential disabled",
2915
- { provider: request.provider, credentialId, error: errorMsg },
2916
- );
2917
- // Neutralize last-good for this cache key: write a null
2918
- // entry with an immediately-elapsed expiry so a future
2919
- // getStale lookup (e.g. on re-login under the same
2920
- // account identity) can't replay the stale report.
2921
- this.#usageCache.set(this.#buildUsageReportCacheKey(request), {
2922
- value: null,
2923
- expiresAt: 0,
2924
- });
2925
- return null;
2926
- }
2927
- }
2928
- }
2910
+ if (request.credential.expiresAt <= Date.now() && AIError.isDefinitiveOAuthFailure(errorMsg)) {
2911
+ // The current access token is unusable, so don't replay an
2912
+ // old usage report after its rotating refresh token is revoked.
2913
+ // This changes cache state only; usage polling remains
2914
+ // non-authoritative about the credential lifecycle.
2915
+ this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
2929
2916
  }
2917
+ // Usage polling is advisory. A refresh can fail while the current
2918
+ // access token remains valid inside the refresh skew, so probe with
2919
+ // that token and never mutate credential state from this path.
2930
2920
  this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
2931
2921
  provider: request.provider,
2932
2922
  error: errorMsg,
@@ -3667,6 +3657,7 @@ export class AuthStorage {
3667
3657
  row.provider as Provider,
3668
3658
  initialRequest.credential,
3669
3659
  refreshedCredential,
3660
+ row.id,
3670
3661
  );
3671
3662
  params = {
3672
3663
  ...params,
@@ -4317,6 +4308,42 @@ export class AuthStorage {
4317
4308
  credential: OAuthCredential,
4318
4309
  credentialId: number | undefined,
4319
4310
  signal?: AbortSignal,
4311
+ ): Promise<OAuthCredentials> {
4312
+ const hasDurableLease =
4313
+ !!this.#store.tryAcquireCredentialRefreshLease &&
4314
+ !!this.#store.getCredentialRefreshLeaseExpiresAt &&
4315
+ !!this.#store.releaseCredentialRefreshLease &&
4316
+ !!this.#store.renewCredentialRefreshLease;
4317
+ if (credentialId !== undefined && hasDurableLease) {
4318
+ const forceRefresh = credential.expires === 0;
4319
+ const result = await this.refreshStoredOAuthCredential(provider, {
4320
+ credentialId,
4321
+ observedCredential: forceRefresh ? undefined : credential,
4322
+ credentialFromRow: row => row,
4323
+ forceRefresh,
4324
+ signal,
4325
+ refresh: (current, refreshSignal) =>
4326
+ this.#requestOAuthCredentialRefresh(
4327
+ provider,
4328
+ current,
4329
+ credentialId,
4330
+ signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
4331
+ ),
4332
+ });
4333
+ if (result.credential) return result.credential;
4334
+ throw new AIError.OAuthError(`OAuth credential no longer exists for provider: ${provider}`, {
4335
+ kind: "token-refresh",
4336
+ provider,
4337
+ });
4338
+ }
4339
+ return this.#requestOAuthCredentialRefresh(provider, credential, credentialId, signal);
4340
+ }
4341
+
4342
+ async #requestOAuthCredentialRefresh(
4343
+ provider: Provider,
4344
+ credential: OAuthCredential,
4345
+ credentialId: number | undefined,
4346
+ signal?: AbortSignal,
4320
4347
  ): Promise<OAuthCredentials> {
4321
4348
  let refreshPromise: Promise<OAuthCredentials>;
4322
4349
  // Caller override > store-level hook > local per-provider refresh.
@@ -4343,10 +4370,9 @@ export class AuthStorage {
4343
4370
  // Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
4344
4371
  // Caller-driven abort jumps the gun on the timeout — the agent's ESC must
4345
4372
  // take priority over the floor timeout.
4346
- let timeout: NodeJS.Timeout | undefined;
4347
- let onAbort: (() => void) | undefined;
4348
4373
  const cancellation = Promise.withResolvers<never>();
4349
- timeout = setTimeout(
4374
+ let onAbort: (() => void) | undefined;
4375
+ const timeout = setTimeout(
4350
4376
  () =>
4351
4377
  cancellation.reject(
4352
4378
  new AIError.OAuthError(`OAuth token refresh timed out for provider: ${provider}`, {
@@ -4367,7 +4393,7 @@ export class AuthStorage {
4367
4393
  try {
4368
4394
  return await Promise.race([refreshPromise, cancellation.promise]);
4369
4395
  } finally {
4370
- if (timeout) clearTimeout(timeout);
4396
+ clearTimeout(timeout);
4371
4397
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
4372
4398
  }
4373
4399
  }
@@ -5359,6 +5385,21 @@ export class AuthStorage {
5359
5385
  Date.now() + AuthStorage.#defaultBackoffMs,
5360
5386
  );
5361
5387
 
5388
+ if (target && AIError.isInvalidatedOAuthTokenError(error)) {
5389
+ const disabledCause = message ?? "upstream reported invalidated OAuth token";
5390
+ const deleted = this.#store.deleteAuthCredentialRemote
5391
+ ? await this.#store.deleteAuthCredentialRemote(target.id, disabledCause)
5392
+ : this.disableCredentialById(target.id, disabledCause);
5393
+ if (deleted) {
5394
+ const latestRows = this.#store.listAuthCredentials(provider);
5395
+ this.#setStoredCredentials(
5396
+ provider,
5397
+ latestRows.map(row => ({ id: row.id, credential: row.credential })),
5398
+ );
5399
+ }
5400
+ return deleted && hasSibling;
5401
+ }
5402
+
5362
5403
  if (target) {
5363
5404
  const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
5364
5405
  if (markSuspect) {
@@ -109,6 +109,9 @@ export function wrapInbandToolStream(
109
109
  case "thinking_end":
110
110
  projector?.thinkingEnd();
111
111
  break;
112
+ case "image_end":
113
+ projector?.keep(event.content);
114
+ break;
112
115
  case "text_delta":
113
116
  // `text()` returns true once the model starts fabricating its own
114
117
  // tool result. In abort mode we cut the turn immediately so the
@@ -206,6 +209,14 @@ class InbandStreamProjector {
206
209
  this.#closeText();
207
210
  this.#closeThinking();
208
211
  this.#partial.content.push(block);
212
+ if (this.#emitEvents && block.type === "image") {
213
+ this.#out.push({
214
+ type: "image_end",
215
+ contentIndex: this.#partial.content.length - 1,
216
+ content: block,
217
+ partial: this.#partial,
218
+ });
219
+ }
209
220
  }
210
221
 
211
222
  // Forward a native tool call's lifecycle live. `source` comes from the inner
@@ -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
  }
@@ -12,6 +12,18 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
12
12
  return isOAuthExpiry(errorMsg);
13
13
  }
14
14
 
15
+ const INVALIDATED_OAUTH_TOKEN_PATTERN = /\binvalidated oauth token\b/i;
16
+
17
+ /** Whether an upstream response explicitly says the supplied OAuth bearer was invalidated. */
18
+ export function isInvalidatedOAuthTokenError(error: unknown): boolean {
19
+ if (typeof error === "object" && error !== null && "errorMessage" in error) {
20
+ const errorMessage = error.errorMessage;
21
+ if (typeof errorMessage === "string" && INVALIDATED_OAUTH_TOKEN_PATTERN.test(errorMessage)) return true;
22
+ }
23
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
24
+ return message !== undefined && INVALIDATED_OAUTH_TOKEN_PATTERN.test(message);
25
+ }
26
+
15
27
  /**
16
28
  * Whether an upstream failure should rotate to a sibling credential: a hard
17
29
  * `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
@@ -22,6 +34,7 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
22
34
  */
23
35
  export function isAuthRetryableError(error: unknown): boolean {
24
36
  if (isUsageLimit(error)) return true;
37
+ if (isInvalidatedOAuthTokenError(error)) return true;
25
38
  const httpStatus = extractHttpStatusFromError(error);
26
39
  if (httpStatus === 401) return true;
27
40
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
@@ -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"))