@oh-my-pi/pi-ai 16.1.14 → 16.1.16

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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.16] - 2026-06-23
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Anthropic-compatible thinking requests sending replayed thinking blocks without `context_management.keep: "all"`, preserving multi-turn reasoning context for API-key providers. API-key requests now also advertise the required `context-management-2025-06-27` beta header so the field is honored instead of rejected. Injected SDK clients, GitHub Copilot's Anthropic proxy, and Vertex rawPredict are excluded because this code path cannot add the beta to caller-owned clients, Copilot strips Anthropic betas and demotes thinking blocks to text upstream, and Vertex expects betas in the JSON body rather than the Anthropic HTTP beta header. ([#3288](https://github.com/can1357/oh-my-pi/issues/3288))
10
+ - Fixed OpenRouter Responses native history replay leaking Gemini reasoning item `format` metadata back into follow-up requests, which caused HTTP 400 rejections while preserving encrypted reasoning replay.
11
+
12
+ ## [16.1.15] - 2026-06-22
13
+
14
+ ### Fixed
15
+
16
+ - Fixed API-key `/login` providers replacing sibling credentials instead of appending new keys for the same provider. ([#3265](https://github.com/can1357/oh-my-pi/issues/3265))
17
+ - Fixed OpenAI Codex OAuth account rotation for quota failures that surface as bare HTTP 429 or `insufficient_quota`, so pre-content failures temporarily block only the exhausted credential and retry a healthy sibling. The 429 status-only fallback applies only to absent/opaque bodies; informative transient bodies (`Too many requests`, `Service overloaded 529`, `Please retry in 5s`, …) defer to `parseRateLimitReason` and stay in the provider's own backoff layer instead of burning sibling credentials. ([#3231](https://github.com/can1357/oh-my-pi/issues/3231))
18
+
5
19
  ## [16.1.14] - 2026-06-22
6
20
 
7
21
  ### Added
@@ -48,8 +48,12 @@ export declare function resolveApiKeyOnce(key: ApiKey | undefined, signal?: Abor
48
48
  export declare function seedApiKeyResolver(seed: string | undefined, resolver: ApiKeyResolver): ApiKeyResolver;
49
49
  /**
50
50
  * Classifies whether an error should trigger a credential refresh/rotation
51
- * retry: a hard `401`, or a rotatable usage-limit ("usage_limit_reached",
52
- * Codex's "you have hit your ChatGPT usage limit", etc.).
51
+ * retry: a hard `401`, body-classified usage limit (Codex
52
+ * `usage_limit_reached`, Anthropic account rate-limit, Google
53
+ * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
54
+ * whose payload did not preserve a richer quota code. Transient 429s
55
+ * (`Too many requests`, per-minute caps) classify as `RATE_LIMIT_EXCEEDED`
56
+ * via {@link parseRateLimitReason} and stay in the upstream-backoff lane.
53
57
  */
54
58
  export declare function isAuthRetryableError(error: unknown): boolean;
55
59
  /**
@@ -475,9 +475,12 @@ export declare function populateResponsesUsageFromResponse(output: AssistantMess
475
475
  input_tokens_details?: {
476
476
  cached_tokens?: number | null;
477
477
  cache_write_tokens?: number | null;
478
+ orchestration_input_tokens?: number | null;
479
+ orchestration_input_cached_tokens?: number | null;
478
480
  } | null;
479
481
  output_tokens_details?: {
480
482
  reasoning_tokens?: number | null;
483
+ orchestration_output_tokens?: number | null;
481
484
  } | null;
482
485
  } | null | undefined): void;
483
486
  /**
@@ -17,4 +17,30 @@ export declare function parseRateLimitReason(errorMessage: string): RateLimitRea
17
17
  * MODEL_CAPACITY gets jitter to prevent thundering herd.
18
18
  */
19
19
  export declare function calculateRateLimitBackoffMs(reason: RateLimitReason): number;
20
+ /**
21
+ * HTTP status codes that, absent richer body classification, represent an
22
+ * account-local usage cap rather than a bad credential or a transient blip.
23
+ * Always combine with {@link isUsageLimitOutcome} when a message is available
24
+ * — a 429 carrying transient rate-limit wording is NOT a usage cap.
25
+ */
26
+ export declare function isUsageLimitStatus(status: number | undefined): boolean;
27
+ /**
28
+ * Returns true for failures that should burn one credential and rotate to a
29
+ * sibling account. Decision tree:
30
+ *
31
+ * 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
32
+ * Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
33
+ * `insufficient_quota`, …) → rotate.
34
+ * 2. Status is not 429 → backoff (caller's domain).
35
+ * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
36
+ * empty JSON, HTTP framing only) → rotate conservatively: the server
37
+ * gave us nothing else to go on.
38
+ * 4. Body has content → defer to {@link parseRateLimitReason}. Only
39
+ * `QUOTA_EXHAUSTED` rotates; `RATE_LIMIT_EXCEEDED` (`Too many requests`,
40
+ * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`),
41
+ * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the
42
+ * provider's own backoff layer so transient 429s don't burn sibling
43
+ * credentials.
44
+ */
45
+ export declare function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean;
20
46
  export declare function isUsageLimitError(errorMessage: string): boolean;
@@ -4,5 +4,4 @@ export declare const nvidiaProvider: {
4
4
  readonly id: "nvidia";
5
5
  readonly name: "NVIDIA";
6
6
  readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
7
- readonly appendApiKeyLogin: true;
8
7
  };
@@ -153,7 +153,6 @@ declare const ALL: ({
153
153
  readonly id: "nvidia";
154
154
  readonly name: "NVIDIA";
155
155
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
156
- readonly appendApiKeyLogin: true;
157
156
  } | {
158
157
  readonly id: "ollama";
159
158
  readonly name: "Ollama (Local OpenAI-compatible)";
@@ -40,8 +40,6 @@ export interface ProviderDefinition {
40
40
  readonly showInLoginList?: boolean;
41
41
  readonly envKeys?: KeyResolver;
42
42
  readonly login?: (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials | string>;
43
- /** String-returning login appends API keys instead of replacing the provider's existing key. */
44
- readonly appendApiKeyLogin?: boolean;
45
43
  readonly refreshToken?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
46
44
  readonly getApiKey?: (credentials: OAuthCredentials) => string;
47
45
  /** Store OAuth credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.1.14",
4
+ "version": "16.1.16",
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.0",
41
- "@oh-my-pi/pi-catalog": "16.1.14",
42
- "@oh-my-pi/pi-utils": "16.1.14",
43
- "@oh-my-pi/pi-wire": "16.1.14",
41
+ "@oh-my-pi/pi-catalog": "16.1.16",
42
+ "@oh-my-pi/pi-utils": "16.1.16",
43
+ "@oh-my-pi/pi-wire": "16.1.16",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -19,14 +19,14 @@
19
19
  */
20
20
 
21
21
  import { Effort } from "@oh-my-pi/pi-catalog/effort";
22
- import { extractRetryHint, logger } from "@oh-my-pi/pi-utils";
22
+ import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils";
23
23
  import type { ApiKeyResolver } from "../auth-retry";
24
24
  import type { AuthStorage } from "../auth-storage";
25
25
  import * as anthropicMessages from "../providers/anthropic-messages-server";
26
26
  import * as openaiChat from "../providers/openai-chat-server";
27
27
  import * as openaiResponses from "../providers/openai-responses-server";
28
28
  import * as piNative from "../providers/pi-native-server";
29
- import { isUsageLimitError } from "../rate-limit-utils";
29
+ import { isUsageLimitError, isUsageLimitOutcome } from "../rate-limit-utils";
30
30
  import { streamSimple } from "../stream";
31
31
  import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
32
32
  import { deterministicUuid } from "../utils/deterministic-id";
@@ -315,7 +315,7 @@ async function refreshGatewayApiKeyAfterAuthError(
315
315
  peer: string,
316
316
  ): Promise<string | undefined> {
317
317
  const message = error instanceof Error ? error.message : String(error);
318
- if (isUsageLimitError(message)) {
318
+ if (isUsageLimitOutcome(extractHttpStatusFromError(error), message)) {
319
319
  const retryAfterMs = extractRetryHint(undefined, message);
320
320
  const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
321
321
  retryAfterMs,
package/src/auth-retry.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
2
  import type { OAuthAccess } from "./auth-storage";
3
- import { isUsageLimitError } from "./rate-limit-utils";
3
+ import { isUsageLimitOutcome } from "./rate-limit-utils";
4
4
 
5
5
  /**
6
6
  * Context passed to an {@link ApiKeyResolver} on each resolution attempt.
@@ -72,15 +72,20 @@ export function seedApiKeyResolver(seed: string | undefined, resolver: ApiKeyRes
72
72
 
73
73
  /**
74
74
  * Classifies whether an error should trigger a credential refresh/rotation
75
- * retry: a hard `401`, or a rotatable usage-limit ("usage_limit_reached",
76
- * Codex's "you have hit your ChatGPT usage limit", etc.).
75
+ * retry: a hard `401`, body-classified usage limit (Codex
76
+ * `usage_limit_reached`, Anthropic account rate-limit, Google
77
+ * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
78
+ * whose payload did not preserve a richer quota code. Transient 429s
79
+ * (`Too many requests`, per-minute caps) classify as `RATE_LIMIT_EXCEEDED`
80
+ * via {@link parseRateLimitReason} and stay in the upstream-backoff lane.
77
81
  */
78
82
  export function isAuthRetryableError(error: unknown): boolean {
79
- if (extractHttpStatusFromError(error) === 401) return true;
83
+ const status = extractHttpStatusFromError(error);
84
+ if (status === 401) return true;
80
85
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
81
- if (!message) return false;
82
- if (extractHttpStatusFromError({ message }) === 401) return true;
83
- return isUsageLimitError(message);
86
+ const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
87
+ if (embeddedStatus === 401) return true;
88
+ return isUsageLimitOutcome(status ?? embeddedStatus, message);
84
89
  }
85
90
 
86
91
  /**
@@ -10,9 +10,9 @@
10
10
  import { Database, type Statement } from "bun:sqlite";
11
11
  import * as fs from "node:fs/promises";
12
12
  import * as path from "node:path";
13
- import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
13
+ import { extractHttpStatusFromError, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
14
14
  import type { ApiKeyResolver } from "./auth-retry";
15
- import { isUsageLimitError } from "./rate-limit-utils";
15
+ import { isUsageLimitOutcome } from "./rate-limit-utils";
16
16
  import { getProviderDefinition } from "./registry";
17
17
  import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
18
18
  import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
@@ -1845,14 +1845,9 @@ export class AuthStorage {
1845
1845
  return;
1846
1846
  }
1847
1847
  const newCredential: ApiKeyCredential = { type: "api_key", key: result };
1848
- const appendApiKeyLogin = "appendApiKeyLogin" in def && def.appendApiKeyLogin === true;
1849
- const stored = appendApiKeyLogin
1850
- ? this.#store.upsertAuthCredentialRemote
1851
- ? await this.#store.upsertAuthCredentialRemote(provider, newCredential)
1852
- : this.#store.upsertAuthCredentialForProvider(provider, newCredential)
1853
- : this.#store.replaceAuthCredentialsRemote
1854
- ? await this.#store.replaceAuthCredentialsRemote(provider, [newCredential])
1855
- : this.#store.replaceAuthCredentialsForProvider(provider, [newCredential]);
1848
+ const stored = this.#store.upsertAuthCredentialRemote
1849
+ ? await this.#store.upsertAuthCredentialRemote(provider, newCredential)
1850
+ : this.#store.upsertAuthCredentialForProvider(provider, newCredential);
1856
1851
  this.#setStoredCredentials(
1857
1852
  provider,
1858
1853
  stored.map(entry => ({ id: entry.id, credential: entry.credential })),
@@ -4089,8 +4084,9 @@ export class AuthStorage {
4089
4084
  if (!sessionCredential) return false;
4090
4085
 
4091
4086
  const error = options?.error;
4087
+ const status = extractHttpStatusFromError(error);
4092
4088
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
4093
- if (message && isUsageLimitError(message)) {
4089
+ if (isUsageLimitOutcome(status, message)) {
4094
4090
  return (
4095
4091
  await this.markUsageLimitReached(provider, sessionId, {
4096
4092
  modelId: options?.modelId,
@@ -120,10 +120,11 @@ export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readon
120
120
  }
121
121
 
122
122
  const midConversationSystemBeta = "mid-conversation-system-2026-04-07";
123
+ const contextManagementBeta = "context-management-2025-06-27";
123
124
  const claudeCodeUtilityBetaDefaults = [
124
125
  "oauth-2025-04-20",
125
126
  "interleaved-thinking-2025-05-14",
126
- "context-management-2025-06-27",
127
+ contextManagementBeta,
127
128
  "prompt-caching-scope-2026-01-05",
128
129
  "structured-outputs-2025-12-15",
129
130
  ] as const;
@@ -131,7 +132,7 @@ const claudeCodeAgentBetaDefaults = [
131
132
  "claude-code-20250219",
132
133
  "oauth-2025-04-20",
133
134
  "interleaved-thinking-2025-05-14",
134
- "context-management-2025-06-27",
135
+ contextManagementBeta,
135
136
  "prompt-caching-scope-2026-01-05",
136
137
  midConversationSystemBeta,
137
138
  "advanced-tool-use-2025-11-20",
@@ -1680,6 +1681,22 @@ const streamAnthropicOnce = (
1680
1681
  // carry it in the Claude Code list).
1681
1682
  extraBetas.push(midConversationSystemBeta);
1682
1683
  }
1684
+ // `context_management.clear_thinking_20251015` requires this beta. OAuth
1685
+ // requests carry it in `claudeCodeAgentBetaDefaults`; API-key requests
1686
+ // need it added explicitly so the field is honored instead of rejected
1687
+ // (#3288). Skip transports where this package cannot deliver the beta
1688
+ // in the form their adapter accepts: Copilot strips Anthropic betas,
1689
+ // and Vertex rawPredict needs betas in the body (`anthropic_beta`),
1690
+ // not as an `anthropic-beta` HTTP header.
1691
+ if (
1692
+ model.reasoning &&
1693
+ options?.thinkingEnabled &&
1694
+ model.provider !== "github-copilot" &&
1695
+ model.provider !== "google-vertex" &&
1696
+ !extraBetas.includes(contextManagementBeta)
1697
+ ) {
1698
+ extraBetas.push(contextManagementBeta);
1699
+ }
1683
1700
 
1684
1701
  const created = createClient(model, {
1685
1702
  model,
@@ -2944,11 +2961,28 @@ function buildParams(
2944
2961
  }
2945
2962
  }
2946
2963
 
2947
- // Pre-compute context_management (depends on thinking).
2948
- const contextManagement =
2949
- isOAuthToken && thinking?.type === "adaptive"
2950
- ? { edits: [{ type: "clear_thinking_20251015" as const, keep: "all" as const }] }
2951
- : undefined;
2964
+ // Pre-compute context_management. Send keep: "all" for every enabled or
2965
+ // adaptive thinking request (OAuth + API-key) — not just OAuth. Without
2966
+ // this directive Anthropic-compatible backends (Z.AI, Kimi, DeepSeek, …)
2967
+ // strip the replayed thinking blocks `replayUnsignedThinking` puts back
2968
+ // on the wire, so the model loses the prior reasoning chain across turns
2969
+ // and the KV cache misses every turn (#3288). Narrowing this guard back
2970
+ // to `isOAuthToken` regresses every API-key thinking provider. Skip
2971
+ // injected clients because this code cannot add the required
2972
+ // `context-management-2025-06-27` beta to caller-owned SDK clients. Skip
2973
+ // Copilot because its proxy strips Anthropic betas and demotes thinking
2974
+ // blocks to text upstream, so `keep: "all"` is a no-op that risks proxy
2975
+ // rejection of an unrecognized field. Skip Vertex rawPredict because that
2976
+ // adapter requires betas in the JSON body (`anthropic_beta`) instead of the
2977
+ // Anthropic HTTP beta header this code can add.
2978
+ const shouldKeepThinkingContext =
2979
+ !options?.client &&
2980
+ model.provider !== "github-copilot" &&
2981
+ model.provider !== "google-vertex" &&
2982
+ (thinking?.type === "adaptive" || thinking?.type === "enabled");
2983
+ const contextManagement = shouldKeepThinkingContext
2984
+ ? { edits: [{ type: "clear_thinking_20251015" as const, keep: "all" as const }] }
2985
+ : undefined;
2952
2986
 
2953
2987
  // Pre-compute output_config.
2954
2988
  const outputConfigEntries: AnthropicOutputConfig = {};
@@ -2401,19 +2401,29 @@ export function populateResponsesUsageFromResponse(
2401
2401
  input_tokens_details?: {
2402
2402
  cached_tokens?: number | null;
2403
2403
  cache_write_tokens?: number | null;
2404
+ orchestration_input_tokens?: number | null;
2405
+ orchestration_input_cached_tokens?: number | null;
2406
+ } | null;
2407
+ output_tokens_details?: {
2408
+ reasoning_tokens?: number | null;
2409
+ orchestration_output_tokens?: number | null;
2404
2410
  } | null;
2405
- output_tokens_details?: { reasoning_tokens?: number | null } | null;
2406
2411
  }
2407
2412
  | null
2408
2413
  | undefined,
2409
2414
  ): void {
2410
2415
  if (!usage) return;
2416
+ const details = usage.input_tokens_details;
2417
+ const outputDetails = usage.output_tokens_details;
2418
+ const orchestrationInputTokens = details?.orchestration_input_tokens ?? 0;
2419
+ const orchestrationInputCachedTokens = details?.orchestration_input_cached_tokens ?? 0;
2420
+ const orchestrationOutputTokens = outputDetails?.orchestration_output_tokens ?? 0;
2411
2421
  const accounting = calculateOpenAIUsageAccounting({
2412
- promptTokens: usage.input_tokens ?? 0,
2413
- outputTokens: usage.output_tokens ?? 0,
2414
- cachedTokens: usage.input_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? 0,
2415
- reasoningTokens: usage.output_tokens_details?.reasoning_tokens ?? 0,
2416
- cacheWriteOpenRouter: usage.input_tokens_details?.cache_write_tokens ?? undefined,
2422
+ promptTokens: (usage.input_tokens ?? 0) + orchestrationInputTokens,
2423
+ outputTokens: (usage.output_tokens ?? 0) + orchestrationOutputTokens,
2424
+ cachedTokens: (details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? 0) + orchestrationInputCachedTokens,
2425
+ reasoningTokens: outputDetails?.reasoning_tokens ?? 0,
2426
+ cacheWriteOpenRouter: details?.cache_write_tokens ?? undefined,
2417
2427
  cacheWriteDeepSeek: usage.prompt_cache_miss_tokens ?? undefined,
2418
2428
  hasDeepSeekCacheHitAndMiss:
2419
2429
  usage.prompt_cache_hit_tokens !== undefined && usage.prompt_cache_miss_tokens !== undefined,
@@ -100,7 +100,55 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
100
100
 
101
101
  /** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
102
102
  const USAGE_LIMIT_PATTERN =
103
- /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|quota.?reached|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?balance/i;
103
+ /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|quota.?reached|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)/i;
104
+
105
+ /**
106
+ * HTTP status codes that, absent richer body classification, represent an
107
+ * account-local usage cap rather than a bad credential or a transient blip.
108
+ * Always combine with {@link isUsageLimitOutcome} when a message is available
109
+ * — a 429 carrying transient rate-limit wording is NOT a usage cap.
110
+ */
111
+ export function isUsageLimitStatus(status: number | undefined): boolean {
112
+ return status === 429;
113
+ }
114
+
115
+ /**
116
+ * Returns true for failures that should burn one credential and rotate to a
117
+ * sibling account. Decision tree:
118
+ *
119
+ * 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
120
+ * Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
121
+ * `insufficient_quota`, …) → rotate.
122
+ * 2. Status is not 429 → backoff (caller's domain).
123
+ * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
124
+ * empty JSON, HTTP framing only) → rotate conservatively: the server
125
+ * gave us nothing else to go on.
126
+ * 4. Body has content → defer to {@link parseRateLimitReason}. Only
127
+ * `QUOTA_EXHAUSTED` rotates; `RATE_LIMIT_EXCEEDED` (`Too many requests`,
128
+ * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`),
129
+ * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the
130
+ * provider's own backoff layer so transient 429s don't burn sibling
131
+ * credentials.
132
+ */
133
+ export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean {
134
+ if (message && isUsageLimitError(message)) return true;
135
+ if (!isUsageLimitStatus(status)) return false;
136
+ if (!message || isOpaqueStatusBody(message)) return true;
137
+ return parseRateLimitReason(message) === "QUOTA_EXHAUSTED";
138
+ }
139
+
140
+ /**
141
+ * A 429 body is opaque when it carries no signal beyond the status itself —
142
+ * empty, whitespace-only, the status digits with HTTP/JSON framing, or
143
+ * generic punctuation. Anything else (retry hints, capacity wording, error
144
+ * descriptions) is informative enough to defer to the classifier.
145
+ */
146
+ function isOpaqueStatusBody(message: string): boolean {
147
+ const cleaned = message
148
+ .replace(/\b429\b/g, "")
149
+ .replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
150
+ return !/[a-z\d]{3,}/i.test(cleaned);
151
+ }
104
152
 
105
153
  export function isUsageLimitError(errorMessage: string): boolean {
106
154
  return USAGE_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage);
@@ -58,5 +58,4 @@ export const nvidiaProvider = {
58
58
  id: "nvidia",
59
59
  name: "NVIDIA",
60
60
  login: (cb: OAuthLoginCallbacks) => loginNvidia(cb),
61
- appendApiKeyLogin: true,
62
61
  } as const satisfies ProviderDefinition;
@@ -44,8 +44,6 @@ export interface ProviderDefinition {
44
44
  readonly envKeys?: KeyResolver;
45
45
  // --- interactive login (OAuthProviderInterface-compatible) ---
46
46
  readonly login?: (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials | string>;
47
- /** String-returning login appends API keys instead of replacing the provider's existing key. */
48
- readonly appendApiKeyLogin?: boolean;
49
47
  readonly refreshToken?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
50
48
  readonly getApiKey?: (credentials: OAuthCredentials) => string;
51
49
  /** Store OAuth credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). */
package/src/stream.ts CHANGED
@@ -48,7 +48,7 @@ import {
48
48
  streamOpenAIResponses,
49
49
  } from "./providers/register-builtins";
50
50
  import { isSyntheticModel, streamSynthetic } from "./providers/synthetic";
51
- import { isUsageLimitError } from "./rate-limit-utils";
51
+ import { isUsageLimitOutcome } from "./rate-limit-utils";
52
52
  import { PROVIDER_REGISTRY } from "./registry";
53
53
  import type {
54
54
  Api,
@@ -384,13 +384,18 @@ function extractStatusFromAssistantError(message: AssistantMessage): number | un
384
384
  function isRetryableUpstreamError(error: unknown, status: number | undefined, message: string | undefined): boolean {
385
385
  // 401 means the credential is bad. Usage-limit phrasing (Codex's
386
386
  // "You have hit your ChatGPT usage limit", Anthropic's "usage_limit_reached",
387
- // Google's "resource_exhausted") means this account is parked but a
388
- // sibling credential can usually pick the request up. Both are
389
- // rotatable via `onAuthError` the auth-gateway maps the former to
390
- // `invalidateCredentialMatching` and the latter to `markUsageLimitReached`.
387
+ // Google's "resource_exhausted", OpenAI's "insufficient_quota") and 429s
388
+ // without transient rate-limit wording mean this account is parked but a
389
+ // sibling credential can usually pick the request up. Both are rotatable
390
+ // via `onAuthError` the auth-gateway maps the former to
391
+ // `invalidateCredentialMatching` and the latter to
392
+ // `markUsageLimitReached`. Transient 429s ("Too many requests",
393
+ // per-minute caps) classify as RATE_LIMIT_EXCEEDED in
394
+ // `parseRateLimitReason` and stay in the provider's own backoff layer
395
+ // instead of burning siblings.
391
396
  if (status === 401) return true;
392
397
  void error;
393
- return !!message && isUsageLimitError(message);
398
+ return isUsageLimitOutcome(status, message);
394
399
  }
395
400
 
396
401
  function createAssistantAuthError(message: AssistantMessage): Error {
package/src/utils.ts CHANGED
@@ -78,6 +78,7 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
78
78
  ): OpenAIResponsesReplayItem | undefined {
79
79
  if (item.type === "item_reference") return undefined;
80
80
  if (item.type === "image_generation_call") return sanitizeOpenAIResponsesImageGenerationCallForReplay(item);
81
+ if (item.type === "reasoning") return sanitizeOpenAIResponsesReasoningItemForReplay(item);
81
82
 
82
83
  // providerPayload stores raw output items; replay strips item ids and keeps only normalized call_id.
83
84
  const { id: _id, ...sanitizedItem } = item;
@@ -88,6 +89,19 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
88
89
  return sanitizedItem as unknown as OpenAIResponsesReplayItem;
89
90
  }
90
91
 
92
+ function sanitizeOpenAIResponsesReasoningItemForReplay(item: Record<string, unknown>): OpenAIResponsesReplayItem {
93
+ const sanitizedItem: Record<string, unknown> = { type: "reasoning" };
94
+ if (Array.isArray(item.summary)) sanitizedItem.summary = item.summary;
95
+ if (Array.isArray(item.content)) sanitizedItem.content = item.content;
96
+ if (typeof item.encrypted_content === "string" || item.encrypted_content === null) {
97
+ sanitizedItem.encrypted_content = item.encrypted_content;
98
+ }
99
+ if (item.status === "in_progress" || item.status === "completed" || item.status === "incomplete") {
100
+ sanitizedItem.status = item.status;
101
+ }
102
+ return sanitizedItem as unknown as OpenAIResponsesReplayItem;
103
+ }
104
+
91
105
  function sanitizeOpenAIResponsesImageGenerationCallForReplay(
92
106
  item: Record<string, unknown>,
93
107
  ): ResponseInputItem.ImageGenerationCall | undefined {