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

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,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+
7
+ - Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs
8
+
9
+ ## [17.0.1] - 2026-07-16
10
+
11
+ ### Fixed
12
+
13
+ - Fixed OpenRouter cost reporting to use the provider's authoritative account charge instead of catalog token-price estimates on both Responses and Chat Completions streams.
14
+ - Fixed OpenAI Responses and Chat Completions requests forwarding unsupported sampling parameters such as `temperature` to o-series and GPT-5+ models, preventing 400 errors for mnemopi memory calls through GitHub Copilot GPT-5.6 Luna. ([#5606](https://github.com/can1357/oh-my-pi/issues/5606))
15
+ - Fixed boolean JSON Schema subschemas (`true`/`false`) in MCP tool inputs triggering `400 INVALID_ARGUMENT` on the Google/Cloud Code Assist (Antigravity) transport by coercing them to their object equivalents (`true` → `{}`, `false` → `{ not: {} }`) before sending ([#5604](https://github.com/can1357/oh-my-pi/issues/5604)).
16
+ - Fixed thinking-enabled Claude requests routed to `google-vertex` sending the `effort-2025-11-24` beta as an `anthropic-beta` HTTP header, which Vertex rawPredict rejects with a 400. The effort beta and the `output_config.effort` field are now gated off the Vertex path the same way `context-management-2025-06-27` already is ([#5614](https://github.com/can1357/oh-my-pi/issues/5614)).
17
+ - Fixed custom and Foundry-routed Anthropic endpoints receiving first-party eager/legacy tool-streaming controls ([#5572](https://github.com/can1357/oh-my-pi/issues/5572)).
18
+ - Parsed Ollama NDJSON response bytes directly instead of decoding and buffering every network chunk as text. ([#5542](https://github.com/can1357/oh-my-pi/issues/5542))
19
+ - Fixed Amazon Bedrock stream error handling for non-`Error` values that `JSON.stringify` cannot serialize ([#5539](https://github.com/can1357/oh-my-pi/issues/5539)).
20
+ - Fixed concurrent provider OAuth refreshes by serializing rotating-token updates across processes, fencing stale writes, and preventing background usage probes from disabling otherwise usable credentials ([#5396](https://github.com/can1357/oh-my-pi/issues/5396)).
21
+ - Fixed OpenAI Codex WebSocket connections ignoring `PI_PROXY`, provider-specific proxy settings, and standard HTTPS/ALL proxy variables ([#5384](https://github.com/can1357/oh-my-pi/issues/5384)).
22
+ - Fixed Anthropic account quota exhaustion (`This request would exceed your account's monthly spend limit`) hanging until the local deadline instead of surfacing the error: the `rate_limit_error` "spend limit" wording is now classified as a persistent usage limit, so it fails fast and rotates to a sibling credential rather than looping in the provider retry backoff. ([#4787](https://github.com/can1357/oh-my-pi/issues/4787))
23
+ - Fixed OpenRouter daily free-model allowance errors (`free-models-per-day`) being treated as transient rate limits, so requests rotate from an exhausted API key to a healthy sibling credential. ([#4832](https://github.com/can1357/oh-my-pi/issues/4832))
24
+
5
25
  ## [17.0.0] - 2026-07-15
6
26
 
7
27
  ### Changed
@@ -559,6 +559,8 @@ export interface InvalidateCredentialMatchingOptions {
559
559
  }
560
560
  /** Options for refreshing one stored OAuth row through durable ownership. */
561
561
  export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
562
+ /** Stable row id when a provider has multiple OAuth credentials. */
563
+ credentialId?: number;
562
564
  observedCredential?: T;
563
565
  credentialFromRow: (credential: OAuthCredential) => T | undefined;
564
566
  forceRefresh?: boolean;
@@ -5,6 +5,8 @@
5
5
  * `@oh-my-pi/pi-ai` entrypoint name used by the coding agent and auth-broker.
6
6
  */
7
7
  export declare function isDefinitiveOAuthFailure(errorMsg: string): boolean;
8
+ /** Whether an upstream response explicitly says the supplied OAuth bearer was invalidated. */
9
+ export declare function isInvalidatedOAuthTokenError(error: unknown): boolean;
8
10
  /**
9
11
  * Whether an upstream failure should rotate to a sibling credential: a hard
10
12
  * `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
@@ -68,6 +68,8 @@ export declare function applyOpenAIServiceTier(params: {
68
68
  * proxy can never skew those costs.
69
69
  */
70
70
  export declare function applyOpenAIResponsesServiceTierCost(model: Pick<Model, "provider">, usage: AssistantMessage["usage"], responseServiceTier: unknown, requestServiceTier: ServiceTier | null | undefined): void;
71
+ /** Reconcile token-price estimates with OpenRouter's authoritative account charge. */
72
+ export declare function applyOpenRouterReportedCost(model: Pick<Model, "provider">, usage: Usage, rawUsage: unknown): void;
71
73
  export interface OpenAIUsageAccountingInput {
72
74
  promptTokens: number;
73
75
  outputTokens: number;
@@ -500,7 +502,9 @@ type CommonSamplingOptions = Pick<StreamOptions, "temperature" | "topP" | "topK"
500
502
  * can let the upstream apply its own default instead of 400-ing on `maxTokens` values that
501
503
  * reflect the model's context window rather than the upstream output limit.
502
504
  */
503
- export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens">): void;
505
+ export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens"> & {
506
+ compat: Pick<ResolvedOpenAISharedCompat, "supportsSamplingParams">;
507
+ }): void;
504
508
  type ReasoningOptions = {
505
509
  reasoning?: string;
506
510
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
@@ -525,7 +525,7 @@ export interface ContextSnapshot {
525
525
  }
526
526
  export interface AssistantMessage {
527
527
  role: "assistant";
528
- content: (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicFallbackContent | ToolCall)[];
528
+ content: (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicFallbackContent | ImageContent | ToolCall)[];
529
529
  api: Api;
530
530
  provider: Provider;
531
531
  model: string;
@@ -716,6 +716,11 @@ export type AssistantMessageEvent = {
716
716
  contentIndex: number;
717
717
  content: string;
718
718
  partial: AssistantMessage;
719
+ } | {
720
+ type: "image_end";
721
+ contentIndex: number;
722
+ content: ImageContent;
723
+ partial: AssistantMessage;
719
724
  } | {
720
725
  type: "toolcall_start";
721
726
  contentIndex: number;
@@ -3,9 +3,9 @@ import { AssistantMessageEventStream } from "./event-stream.js";
3
3
  export declare const MAX_EMPTY_COMPLETION_RETRIES = 2;
4
4
  export declare const EMPTY_COMPLETION_BASE_DELAY_MS = 500;
5
5
  /**
6
- * Whether a completed assistant message carries content worth delivering: a tool
7
- * call or any non-whitespace text. An empty/whitespace-only message — or one
8
- * that only ever produced thinking — is the "empty response" failure.
6
+ * Whether a completed assistant message carries content worth delivering: an
7
+ * image, tool call, or any non-whitespace text. An empty/whitespace-only message
8
+ * — or one that only ever produced thinking — is the "empty response" failure.
9
9
  */
10
10
  export declare function hasVisibleAssistantContent(message: AssistantMessage): boolean;
11
11
  interface EmptyCompletionRetryOptions {
@@ -1,7 +1,9 @@
1
1
  import { type DescriptionSpillFormat } from "./spill.js";
2
2
  import { type JsonObject } from "./types.js";
3
- export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners";
3
+ export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners" | "not";
4
4
  export interface NormalizeSchemaOptions {
5
+ /** Coerce JSON Schema boolean subschemas for providers whose wire cannot encode them. */
6
+ coerceBooleanSubschemas?: boolean;
5
7
  unsupportedFields: (key: string) => boolean;
6
8
  normalizeFieldNames: boolean;
7
9
  collapseNullFields: boolean;
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.0",
4
+ "version": "17.0.1",
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.0",
42
- "@oh-my-pi/pi-utils": "17.0.0",
43
- "@oh-my-pi/pi-wire": "17.0.0",
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",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
package/src/auth-retry.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { OAuthAccess } from "./auth-storage";
2
2
  import * as AIError from "./error";
3
- import { isAuthRetryableError } from "./error/auth-classify";
3
+ import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify";
4
4
  import { isUsageLimit } from "./error/flags";
5
5
  import { isUsageLimitOutcome } from "./error/rate-limit";
6
6
 
@@ -90,7 +90,7 @@ export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
90
90
  export const AUTH_RETRY_MAX_ATTEMPTS = 64;
91
91
 
92
92
  function isDirectCredentialRotationError(error: unknown): boolean {
93
- if (isUsageLimit(error)) return true;
93
+ if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true;
94
94
  const status = AIError.status(error);
95
95
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
96
96
  return isUsageLimitOutcome(status, message);
@@ -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
@@ -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;
@@ -19,6 +19,8 @@ const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s
19
19
  const ACCOUNT_RATE_LIMIT_PATTERN =
20
20
  /\baccount(?:'s)?\b[^\n]{0,80}\brate.?limit\b|\brate.?limit\b[^\n]{0,80}\baccount\b/i;
21
21
  const INSUFFICIENT_BALANCE_PATTERN = /insufficient.?balance/i;
22
+ const SPEND_LIMIT_PATTERN = /spend.?limit/i;
23
+ const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b/i;
22
24
 
23
25
  /**
24
26
  * Classify a rate-limit error message into a reason category.
@@ -54,6 +56,14 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
54
56
  return "QUOTA_EXHAUSTED";
55
57
  }
56
58
 
59
+ if (SPEND_LIMIT_PATTERN.test(errorMessage)) {
60
+ return "QUOTA_EXHAUSTED";
61
+ }
62
+
63
+ if (OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)) {
64
+ return "QUOTA_EXHAUSTED";
65
+ }
66
+
57
67
  if (
58
68
  lower.includes("per minute") ||
59
69
  lower.includes("rate limit") ||
@@ -163,5 +173,10 @@ export function isOpaqueStatusBody(message: string): boolean {
163
173
  * {@link isUsageLimitOutcome} uses it for the account-rotation decision.
164
174
  */
165
175
  export function matchesUsageLimitText(errorMessage: string): boolean {
166
- return USAGE_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage);
176
+ return (
177
+ USAGE_LIMIT_PATTERN.test(errorMessage) ||
178
+ SPEND_LIMIT_PATTERN.test(errorMessage) ||
179
+ ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage) ||
180
+ OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)
181
+ );
167
182
  }