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

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.15] - 2026-06-22
6
+
7
+ ### Fixed
8
+
9
+ - 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))
10
+ - 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))
11
+
5
12
  ## [16.1.14] - 2026-06-22
6
13
 
7
14
  ### 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.15",
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.15",
42
+ "@oh-my-pi/pi-utils": "16.1.15",
43
+ "@oh-my-pi/pi-wire": "16.1.15",
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,
@@ -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 {