@oh-my-pi/pi-ai 16.4.5 → 16.4.6

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.6] - 2026-07-12
6
+
7
+ ### Added
8
+
9
+ - Added asynchronous `invalidateUsageCache` method to clear cached usage reports
10
+ - Added support for cross-service usage cache invalidation between AuthStorage and AuthBroker
11
+
12
+ ### Fixed
13
+
14
+ - Fixed OAuth credential resolution returning "No API key found" when every plan-eligible OpenAI Codex account was rate-limit blocked and the only unblocked account failed the model's plan gate: resolution now runs a last-resort ladder that first yields a plan-fitting account regardless of usage blocks (so callers get real usage-limit retry semantics), then tries every account with the plan filter dropped before reporting no credential
15
+
5
16
  ## [16.4.5] - 2026-07-11
6
17
 
7
18
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  import type { AuthCredential } from "../auth-storage.js";
2
- import type { CredentialBlockRequest, CredentialBlockResponse, CredentialBlocksDeleteResponse, CredentialDisableResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse } from "./types.js";
2
+ import type { CredentialBlockRequest, CredentialBlockResponse, CredentialBlocksDeleteResponse, CredentialDisableResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse, UsageStaleResponse } from "./types.js";
3
3
  export interface AuthBrokerClientOptions {
4
4
  /** Base URL (e.g. `https://broker.tailnet:8765`). Trailing slashes are trimmed. */
5
5
  url: string;
@@ -60,6 +60,7 @@ export declare class AuthBrokerClient {
60
60
  signal?: AbortSignal;
61
61
  }): AsyncGenerator<SnapshotStreamEvent>;
62
62
  fetchUsage(signal?: AbortSignal): Promise<UsageResponse>;
63
+ notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse>;
63
64
  refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse>;
64
65
  disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse>;
65
66
  uploadCredential(provider: string, credential: AuthCredential, signal?: AbortSignal): Promise<CredentialUploadResponse>;
@@ -80,6 +80,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
80
80
  getCache(key: string): string | null;
81
81
  setCache(key: string, value: string, expiresAtSec: number): void;
82
82
  cleanExpiredCache(): void;
83
+ invalidateUsageCache(signal?: AbortSignal): Promise<void>;
83
84
  /**
84
85
  * Store-level hook consumed by `AuthStorage` — routes refresh through the
85
86
  * broker so the actual refresh token never leaves the broker host. Returns
@@ -56,6 +56,10 @@ export interface CredentialBlockResponse {
56
56
  export interface CredentialBlocksDeleteResponse {
57
57
  ok: boolean;
58
58
  }
59
+ /** POST /v1/usage/stale response body. */
60
+ export interface UsageStaleResponse {
61
+ ok: boolean;
62
+ }
59
63
  /**
60
64
  * POST /v1/credential request body. The OAuth `refresh` must be the *real*
61
65
  * refresh token (not the sentinel) — the broker is the canonical writer.
@@ -425,6 +425,9 @@ export declare const credentialBlockResponseSchema: import("arktype/internal/var
425
425
  export declare const credentialBlocksDeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
426
426
  ok: boolean;
427
427
  }, {}>;
428
+ export declare const usageStaleResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
429
+ ok: boolean;
430
+ }, {}>;
428
431
  export declare const credentialUploadRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
429
432
  provider: string;
430
433
  credential: {
@@ -233,6 +233,8 @@ export interface CredentialRefreshLeaseFence {
233
233
  }
234
234
  export interface AuthCredentialStore {
235
235
  close(): void;
236
+ /** Optional hook to notify the underlying store that usage report cache is stale. */
237
+ invalidateUsageCache?(signal?: AbortSignal): Promise<void>;
236
238
  listAuthCredentials(provider?: string): StoredAuthCredential[];
237
239
  updateAuthCredential(id: number, credential: AuthCredential): void;
238
240
  deleteAuthCredential(id: number, disabledCause: string): void;
@@ -930,6 +932,13 @@ export declare class AuthStorage {
930
932
  baseUrlResolver?: (provider: string) => string | undefined;
931
933
  signal?: AbortSignal;
932
934
  }): Promise<ResetCreditRedeemOutcome>;
935
+ /**
936
+ * Force-invalidate cached usage reports so the next fetch retrieves fresh
937
+ * values from upstream providers. If `provider` is specified, only that
938
+ * provider's credentials are invalidated; otherwise, all credentials in the
939
+ * store are invalidated.
940
+ */
941
+ invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
933
942
  invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
934
943
  invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
935
944
  /**
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.4.5",
4
+ "version": "16.4.6",
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.4.5",
42
- "@oh-my-pi/pi-utils": "16.4.5",
43
- "@oh-my-pi/pi-wire": "16.4.5",
41
+ "@oh-my-pi/pi-catalog": "16.4.6",
42
+ "@oh-my-pi/pi-utils": "16.4.6",
43
+ "@oh-my-pi/pi-wire": "16.4.6",
44
44
  "arktype": "2.2.2",
45
45
  "zod": "^4"
46
46
  },
@@ -21,6 +21,7 @@ import type {
21
21
  SnapshotResponse,
22
22
  SnapshotStreamEvent,
23
23
  UsageResponse,
24
+ UsageStaleResponse,
24
25
  } from "./types";
25
26
  import {
26
27
  credentialBlockResponseSchema,
@@ -32,6 +33,7 @@ import {
32
33
  snapshotResponseSchema,
33
34
  snapshotStreamEventSchema,
34
35
  usageResponseSchema,
36
+ usageStaleResponseSchema,
35
37
  } from "./wire-schemas";
36
38
 
37
39
  export interface AuthBrokerClientOptions {
@@ -242,6 +244,13 @@ export class AuthBrokerClient {
242
244
  return this.#request<UsageResponse>("GET", "/v1/usage", { schema: usageResponseSchema, signal });
243
245
  }
244
246
 
247
+ notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse> {
248
+ return this.#request<UsageStaleResponse>("POST", "/v1/usage/stale", {
249
+ schema: usageStaleResponseSchema,
250
+ signal,
251
+ });
252
+ }
253
+
245
254
  async refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse> {
246
255
  return this.#request<CredentialRefreshResponse>("POST", `/v1/credential/${id}/refresh`, {
247
256
  schema: credentialRefreshResponseSchema,
@@ -794,6 +794,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
794
794
  }
795
795
  }
796
796
 
797
+ async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
798
+ this.#invalidateUsageCache();
799
+ await this.#client.notifyUsageStale(signal).catch(err => {
800
+ logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
801
+ });
802
+ }
803
+
797
804
  #invalidateUsageCache(): void {
798
805
  this.#usageCache = undefined;
799
806
  this.#usageInflight = undefined;
@@ -608,6 +608,17 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
608
608
  return json(502, { error: message });
609
609
  }
610
610
  }
611
+ if (req.method === "POST" && pathname === "/v1/usage/stale") {
612
+ try {
613
+ opts.storage.invalidateUsageCache?.();
614
+ logger.info("auth-broker usage cache invalidated", { peer });
615
+ return json(200, { ok: true });
616
+ } catch (error) {
617
+ const message = error instanceof Error ? error.message : String(error);
618
+ logger.warn("auth-broker usage cache invalidation failed", { peer, error: message });
619
+ return json(500, { error: message });
620
+ }
621
+ }
611
622
  const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
612
623
  if (refreshMatch) {
613
624
  const id = Number.parseInt(refreshMatch[1], 10);
@@ -75,6 +75,11 @@ export interface CredentialBlocksDeleteResponse {
75
75
  ok: boolean;
76
76
  }
77
77
 
78
+ /** POST /v1/usage/stale response body. */
79
+ export interface UsageStaleResponse {
80
+ ok: boolean;
81
+ }
82
+
78
83
  /**
79
84
  * POST /v1/credential request body. The OAuth `refresh` must be the *real*
80
85
  * refresh token (not the sentinel) — the broker is the canonical writer.
@@ -258,6 +258,11 @@ export const credentialBlocksDeleteResponseSchema = type({
258
258
  ok: "boolean",
259
259
  });
260
260
 
261
+ export const usageStaleResponseSchema = type({
262
+ "+": "reject",
263
+ ok: "boolean",
264
+ });
265
+
261
266
  // ─── Upload ────────────────────────────────────────────────────────────────
262
267
 
263
268
  export const credentialUploadRequestSchema = type({
@@ -315,6 +315,8 @@ export interface CredentialRefreshLeaseFence {
315
315
 
316
316
  export interface AuthCredentialStore {
317
317
  close(): void;
318
+ /** Optional hook to notify the underlying store that usage report cache is stale. */
319
+ invalidateUsageCache?(signal?: AbortSignal): Promise<void>;
318
320
  listAuthCredentials(provider?: string): StoredAuthCredential[];
319
321
  updateAuthCredential(id: number, credential: AuthCredential): void;
320
322
  deleteAuthCredential(id: number, disabledCause: string): void;
@@ -3739,8 +3741,17 @@ export class AuthStorage {
3739
3741
 
3740
3742
  /**
3741
3743
  * Resolves an OAuth credential, trying credentials in priority order.
3742
- * Skips blocked credentials and checks usage limits for providers with usage data.
3743
- * Falls back to earliest-unblocking credential if all are blocked.
3744
+ *
3745
+ * Resolution ladder a request in hand always beats "no API key":
3746
+ * 1. strict: unblocked credentials only, usage limits respected, plan
3747
+ * filter enforced (when any account is confirmed eligible);
3748
+ * 2. plan-fitting last resort: same plan filter, but blocked/exhausted
3749
+ * accounts are allowed (blocked candidates rank earliest-unblocking
3750
+ * first) so the caller gets real usage-limit semantics from the wire
3751
+ * instead of a missing key;
3752
+ * 3. unfiltered last resort: the plan filter matched nothing usable —
3753
+ * skip it and try every account once; the server is the final arbiter
3754
+ * of model access.
3744
3755
  *
3745
3756
  * Returns both the API key bytes for outbound requests AND the refreshed
3746
3757
  * {@link OAuthCredential} so callers needing identity metadata (account id,
@@ -3890,42 +3901,34 @@ export class AuthStorage {
3890
3901
  hasPlanRequirement &&
3891
3902
  candidates.some(candidate => getOpenAICodexPlanEligibility(candidate.usage, planRequirement) === true);
3892
3903
 
3893
- const fallback = candidates[0];
3904
+ const passes: Array<{ allowBlocked: boolean; enforcePlanRequirement: boolean }> = [
3905
+ { allowBlocked: false, enforcePlanRequirement },
3906
+ { allowBlocked: true, enforcePlanRequirement },
3907
+ ];
3908
+ if (enforcePlanRequirement) passes.push({ allowBlocked: true, enforcePlanRequirement: false });
3894
3909
 
3895
- for (const candidate of candidates) {
3896
- const resolved = await this.#tryOAuthCredential(
3897
- provider,
3898
- candidate.selection,
3899
- providerKey,
3900
- sessionId,
3901
- options,
3902
- {
3903
- checkUsage,
3904
- allowBlocked: false,
3905
- prefetchedUsage: candidate.usage,
3906
- usagePrechecked: candidate.usageChecked,
3907
- planRequirement,
3908
- enforcePlanRequirement,
3909
- strategy,
3910
- rankingContext,
3911
- blockScope,
3912
- },
3913
- );
3914
- if (resolved) return resolved;
3915
- }
3916
-
3917
- if (fallback && this.#isCredentialBlocked(provider, providerKey, fallback.selection.index, blockScope)) {
3918
- return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
3919
- checkUsage,
3920
- allowBlocked: true,
3921
- prefetchedUsage: fallback.usage,
3922
- usagePrechecked: fallback.usageChecked,
3923
- planRequirement,
3924
- enforcePlanRequirement,
3925
- strategy,
3926
- rankingContext,
3927
- blockScope,
3928
- });
3910
+ for (const pass of passes) {
3911
+ for (const candidate of candidates) {
3912
+ const resolved = await this.#tryOAuthCredential(
3913
+ provider,
3914
+ candidate.selection,
3915
+ providerKey,
3916
+ sessionId,
3917
+ options,
3918
+ {
3919
+ checkUsage,
3920
+ allowBlocked: pass.allowBlocked,
3921
+ prefetchedUsage: candidate.usage,
3922
+ usagePrechecked: candidate.usageChecked,
3923
+ planRequirement,
3924
+ enforcePlanRequirement: pass.enforcePlanRequirement,
3925
+ strategy,
3926
+ rankingContext,
3927
+ blockScope,
3928
+ },
3929
+ );
3930
+ if (resolved) return resolved;
3931
+ }
3929
3932
  }
3930
3933
 
3931
3934
  return undefined;
@@ -4666,6 +4669,11 @@ export class AuthStorage {
4666
4669
  });
4667
4670
  if (result.ok) {
4668
4671
  this.#invalidateUsageReportCache(provider, baseUrl);
4672
+ if (this.#store.invalidateUsageCache) {
4673
+ await this.#store.invalidateUsageCache(options.signal).catch(err => {
4674
+ logger.debug("Failed to notify store of stale usage", { err });
4675
+ });
4676
+ }
4669
4677
  // The window this credential was blocked on (by markUsageLimitReached)
4670
4678
  // is now reset, so lift its temporary block — otherwise selection
4671
4679
  // keeps skipping/under-ranking the freshly-reset account.
@@ -4691,6 +4699,40 @@ export class AuthStorage {
4691
4699
  }
4692
4700
  }
4693
4701
 
4702
+ /**
4703
+ * Force-invalidate cached usage reports so the next fetch retrieves fresh
4704
+ * values from upstream providers. If `provider` is specified, only that
4705
+ * provider's credentials are invalidated; otherwise, all credentials in the
4706
+ * store are invalidated.
4707
+ */
4708
+ async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
4709
+ if (provider) {
4710
+ this.#invalidateUsageReportCache(provider);
4711
+ } else {
4712
+ this.#usageCacheEpoch += 1;
4713
+ const expired = Date.now() - 1;
4714
+ try {
4715
+ const credentials = this.#store.listAuthCredentials();
4716
+ for (const entry of credentials) {
4717
+ if (entry.credential.type !== "oauth") continue;
4718
+ const cacheKey = this.#buildUsageReportCacheKey(
4719
+ this.#buildUsageRequestForOauth(entry.provider, entry.credential),
4720
+ );
4721
+ const existing = this.#usageCache.getStale<UsageReport | null>(cacheKey);
4722
+ this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: expired });
4723
+ }
4724
+ } catch (err) {
4725
+ logger.debug("Failed to list auth credentials for complete usage cache invalidation", { err });
4726
+ }
4727
+ }
4728
+
4729
+ if (this.#store.invalidateUsageCache) {
4730
+ await this.#store.invalidateUsageCache(signal).catch(err => {
4731
+ logger.debug("Failed to notify store of stale usage", { err });
4732
+ });
4733
+ }
4734
+ }
4735
+
4694
4736
  #invalidateUsageReportCacheForProviderKey(providerKey: string): void {
4695
4737
  const oauthSuffix = ":oauth";
4696
4738
  if (!providerKey.endsWith(oauthSuffix)) return;