@oh-my-pi/pi-ai 16.3.10 → 16.3.12
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 +26 -0
- package/dist/types/auth-retry.d.ts +3 -1
- package/dist/types/auth-storage.d.ts +13 -8
- package/dist/types/providers/anthropic.d.ts +1 -0
- package/dist/types/providers/openai-codex-responses.d.ts +40 -3
- package/dist/types/providers/openai-shared.d.ts +4 -3
- package/dist/types/types.d.ts +2 -0
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +55 -5
- package/src/auth-gateway/server.ts +1 -0
- package/src/auth-retry.ts +7 -2
- package/src/auth-storage.ts +210 -46
- package/src/providers/anthropic.ts +49 -3
- package/src/providers/openai-codex/request-transformer.ts +54 -9
- package/src/providers/openai-codex-responses.ts +317 -56
- package/src/providers/openai-responses.ts +1 -1
- package/src/providers/openai-shared.ts +20 -6
- package/src/registry/oauth/callback-server.ts +19 -7
- package/src/stream.ts +7 -1
- package/src/types.ts +2 -0
- package/src/usage/openai-codex.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.12] - 2026-07-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `AssistantMessage.toolCallAbortMessages` for per-tool placeholder labels on aborted assistant turns ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed Anthropic replay 400s (`tool_use ids were found without tool_result blocks immediately after`) when a persisted assistant turn carries content after a completed tool call — such as a mid-turn `server-side-fallback` handoff (fallback block plus continued text/tool calls after the primary model's `tool_use`) or trailing text from cross-provider replays — by stable-partitioning assistant content so all `tool_use` blocks trail the non-`tool_use` chain. ([#4781](https://github.com/can1357/oh-my-pi/issues/4781), [#544](https://github.com/can1357/oh-my-pi/issues/544))
|
|
14
|
+
- Fixed access-token-only OAuth credentials attempting token refresh with an empty refresh token after expiry.
|
|
15
|
+
- Fixed gateway usage-limit retries falling through to cross-provider model fallback before trying a sibling credential from the same provider.
|
|
16
|
+
- Fixed Codex usage-limit rotation treating Plus and K-12 accounts as separate quota groups for shared 5-hour/7-day windows.
|
|
17
|
+
- Fixed OpenAI Responses streams that end with `response.done` being misclassified as premature stream closures.
|
|
18
|
+
- Fixed OpenCode Go `/login` credentials being shadowed by an existing `OPENCODE_API_KEY` env fallback after switching accounts. ([#4688](https://github.com/can1357/oh-my-pi/issues/4688))
|
|
19
|
+
- Fixed OpenAI Codex WebSocket continuations to treat proxy stale-anchor codes such as `codex_previous_response_stale` as an expired `previous_response_id` chain — same recovery class as the OpenAI-standard `previous_response_not_found` — so the turn is retried with full context instead of surfacing the error to the user ([#4624](https://github.com/can1357/oh-my-pi/issues/4624)).
|
|
20
|
+
- Fixed Azure Foundry Anthropic utility requests to omit the structured-output beta whenever strict tools are disabled, preventing `structured_outputs not supported in your workspace` failures for Sonnet 5 compaction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)).
|
|
21
|
+
- Fixed OAuth `launchUrl` advertisement for flows whose redirect never returns to the local callback server: custom-scheme redirects (e.g. GitLab Duo's `vscode://` URI, which `new URL` parses without complaint) and fixed non-loopback hosts no longer receive a `http://localhost:<port>/launch` copy target that misrepresents the callback endpoint and resolves nowhere for remote users.
|
|
22
|
+
- Codex load balancing: clear stale persisted and in-memory usage-limit blocks for an `openai-codex` account when a fresh live usage report shows it is allowed and below all limits, including broker-backed gateway snapshots, so traffic returns to recovered accounts instead of funneling to one sibling.
|
|
23
|
+
|
|
24
|
+
## [16.3.11] - 2026-07-06
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Fixed `openai-codex-responses` fresh plan execution requests that contained only system/developer guidance by mirroring the final instruction as user input so Codex accepts the first turn. ([#4714](https://github.com/can1357/oh-my-pi/issues/4714))
|
|
29
|
+
- Fixed Codex WebSocket compact/resume delta diagnostics to record request shape and raw-vs-displayed usage buckets, so persistent server-reported uncached suffixes without `orchestration_*` fields are visible in debug stats. ([#4707](https://github.com/can1357/oh-my-pi/issues/4707))
|
|
30
|
+
|
|
5
31
|
## [16.3.10] - 2026-07-06
|
|
6
32
|
|
|
7
33
|
### Fixed
|
|
@@ -21,6 +21,8 @@ export interface ApiKeyResolveContext {
|
|
|
21
21
|
lastChance: boolean;
|
|
22
22
|
/** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
|
|
23
23
|
error: unknown;
|
|
24
|
+
/** Bearer used by the failed attempt, when the caller can expose it. */
|
|
25
|
+
previousKey?: string;
|
|
24
26
|
/** Caller cancel signal, threaded into any credential refresh / rotation work. */
|
|
25
27
|
signal?: AbortSignal;
|
|
26
28
|
}
|
|
@@ -56,7 +58,7 @@ export { isAuthRetryableError };
|
|
|
56
58
|
*/
|
|
57
59
|
export declare const AUTH_RETRY_STEPS: readonly boolean[];
|
|
58
60
|
/** Resolve a single retry step, swallowing resolver failures into `undefined`. */
|
|
59
|
-
export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal): Promise<string | undefined>;
|
|
61
|
+
export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal, previousKey?: string): Promise<string | undefined>;
|
|
60
62
|
/**
|
|
61
63
|
* Runs an auth-protected operation through the central a/b/c retry policy.
|
|
62
64
|
*
|
|
@@ -16,6 +16,7 @@ import { type CodexResetConsumeCode, type CodexResetCredit } from "./usage/opena
|
|
|
16
16
|
export type ApiKeyCredential = {
|
|
17
17
|
type: "api_key";
|
|
18
18
|
key: string;
|
|
19
|
+
source?: "login";
|
|
19
20
|
};
|
|
20
21
|
export type OAuthCredential = {
|
|
21
22
|
type: "oauth";
|
|
@@ -678,8 +679,8 @@ export declare class AuthStorage {
|
|
|
678
679
|
/**
|
|
679
680
|
* Classify where a provider's auth comes from, following the same precedence
|
|
680
681
|
* as {@link AuthStorage.getApiKey}: runtime override → config override →
|
|
681
|
-
* stored OAuth → env var → stored api_key →
|
|
682
|
-
* undefined when no auth is configured.
|
|
682
|
+
* stored OAuth → login-stored api_key → env var → stored api_key →
|
|
683
|
+
* fallback resolver. Returns undefined when no auth is configured.
|
|
683
684
|
*
|
|
684
685
|
* Compact, structured counterpart to {@link describeCredentialSource}.
|
|
685
686
|
*/
|
|
@@ -792,6 +793,7 @@ export declare class AuthStorage {
|
|
|
792
793
|
retryAfterMs?: number;
|
|
793
794
|
baseUrl?: string;
|
|
794
795
|
modelId?: string;
|
|
796
|
+
apiKey?: string;
|
|
795
797
|
signal?: AbortSignal;
|
|
796
798
|
}): Promise<UsageLimitMarkResult>;
|
|
797
799
|
/**
|
|
@@ -807,9 +809,10 @@ export declare class AuthStorage {
|
|
|
807
809
|
* 1. Runtime override (CLI --api-key)
|
|
808
810
|
* 2. Config override (models.yml `providers.<name>.apiKey`)
|
|
809
811
|
* 3. OAuth token from storage (auto-refreshed)
|
|
810
|
-
* 4.
|
|
811
|
-
* 5.
|
|
812
|
-
* 6.
|
|
812
|
+
* 4. API key persisted by a successful `/login`
|
|
813
|
+
* 5. Environment variable
|
|
814
|
+
* 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
|
|
815
|
+
* 7. Fallback resolver (models.yml custom providers, last-resort)
|
|
813
816
|
*/
|
|
814
817
|
getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
|
|
815
818
|
/**
|
|
@@ -908,6 +911,7 @@ export declare class AuthStorage {
|
|
|
908
911
|
rotateSessionCredential(provider: string, sessionId: string | undefined, options?: {
|
|
909
912
|
error?: unknown;
|
|
910
913
|
modelId?: string;
|
|
914
|
+
apiKey?: string;
|
|
911
915
|
signal?: AbortSignal;
|
|
912
916
|
}): Promise<boolean>;
|
|
913
917
|
/**
|
|
@@ -986,9 +990,10 @@ export declare class AuthStorage {
|
|
|
986
990
|
* 1. Runtime override (`--api-key`).
|
|
987
991
|
* 2. Config override (`models.yml` `providers.<name>.apiKey`).
|
|
988
992
|
* 3. Stored OAuth credential.
|
|
989
|
-
* 4.
|
|
990
|
-
* 5.
|
|
991
|
-
* 6.
|
|
993
|
+
* 4. API key persisted by a successful `/login`.
|
|
994
|
+
* 5. Env var — overrides a stored static api_key (e.g. a stale broker copy).
|
|
995
|
+
* 6. Stored api_key credential.
|
|
996
|
+
* 7. Fallback resolver.
|
|
992
997
|
*
|
|
993
998
|
* The string is purely informational; consumers must not parse it.
|
|
994
999
|
*/
|
|
@@ -35,10 +35,46 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
|
|
|
35
35
|
onModerationMetadata?: (metadata: unknown) => void;
|
|
36
36
|
}
|
|
37
37
|
type CodexTransport = "sse" | "websocket";
|
|
38
|
+
/** Shape of the Codex request sent on the latest provider turn. */
|
|
39
|
+
export interface OpenAICodexTurnRequestDiagnostics {
|
|
40
|
+
transport: "sse" | "websocket";
|
|
41
|
+
previousResponseIdPresent: boolean;
|
|
42
|
+
inputItemCount: number;
|
|
43
|
+
inputItemTypes: string[];
|
|
44
|
+
firstInputItemType?: string;
|
|
45
|
+
inputJsonBytes: number;
|
|
46
|
+
promptCacheKey?: string;
|
|
47
|
+
toolsHash?: string;
|
|
48
|
+
optionsHash: string;
|
|
49
|
+
canAppendBeforeRequest: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** Raw provider usage plus the normalized buckets OMP displays for the latest Codex turn. */
|
|
52
|
+
export interface OpenAICodexTurnUsageDiagnostics {
|
|
53
|
+
rawInputTokens: number;
|
|
54
|
+
rawCachedTokens: number;
|
|
55
|
+
rawUncachedTokens: number;
|
|
56
|
+
rawOutputTokens: number;
|
|
57
|
+
rawTotalTokens?: number;
|
|
58
|
+
rawOrchestrationInputTokens?: number;
|
|
59
|
+
rawOrchestrationCachedTokens?: number;
|
|
60
|
+
rawOrchestrationOutputTokens?: number;
|
|
61
|
+
displayedInputTokens: number;
|
|
62
|
+
displayedOutputTokens: number;
|
|
63
|
+
displayedCacheReadTokens: number;
|
|
64
|
+
displayedCacheWriteTokens: number;
|
|
65
|
+
displayedTotalTokens: number;
|
|
66
|
+
displayedOrchestrationInputTokens: number;
|
|
67
|
+
displayedOrchestrationCacheReadTokens: number;
|
|
68
|
+
displayedOrchestrationOutputTokens: number;
|
|
69
|
+
}
|
|
70
|
+
/** Latest Codex turn request/usage diagnostics exposed to debug UIs and tests. */
|
|
71
|
+
export interface OpenAICodexTurnDiagnostics {
|
|
72
|
+
request: OpenAICodexTurnRequestDiagnostics;
|
|
73
|
+
usage?: OpenAICodexTurnUsageDiagnostics;
|
|
74
|
+
}
|
|
38
75
|
/**
|
|
39
|
-
* Per-session request-shape counters
|
|
40
|
-
*
|
|
41
|
-
* too (the shared chained-request builder records every request it shapes).
|
|
76
|
+
* Per-session request-shape counters and latest turn diagnostics. Despite the
|
|
77
|
+
* name, these cover both transports.
|
|
42
78
|
*/
|
|
43
79
|
export interface OpenAICodexWebSocketDebugStats {
|
|
44
80
|
fullContextRequests: number;
|
|
@@ -46,6 +82,7 @@ export interface OpenAICodexWebSocketDebugStats {
|
|
|
46
82
|
lastInputItems: number;
|
|
47
83
|
lastDeltaInputItems?: number;
|
|
48
84
|
lastPreviousResponseId?: string;
|
|
85
|
+
lastTurn?: OpenAICodexTurnDiagnostics;
|
|
49
86
|
}
|
|
50
87
|
/** @internal Exported for tests. */
|
|
51
88
|
export declare function normalizeCodexToolChoice(choice: ToolChoice | undefined, tools?: Tool[], model?: Model<"openai-codex-responses">): string | Record<string, unknown> | undefined;
|
|
@@ -400,9 +400,10 @@ export interface ProcessResponsesStreamOptions {
|
|
|
400
400
|
onFirstToken?: () => void;
|
|
401
401
|
onOutputItemDone?: (item: ResponseOutputItem) => void;
|
|
402
402
|
/**
|
|
403
|
-
* Called when a terminal `response.completed
|
|
404
|
-
* is successfully processed. Only invoked on the
|
|
405
|
-
* thrown failure (`response.failed`) and
|
|
403
|
+
* Called when a terminal `response.completed`, `response.incomplete`, or
|
|
404
|
+
* `response.done` event is successfully processed. Only invoked on the
|
|
405
|
+
* successful-completion path; thrown failure (`response.failed`) and
|
|
406
|
+
* cancellation paths never call this.
|
|
406
407
|
* Used by callers to detect premature stream closure (i.e. the stream ended
|
|
407
408
|
* without a recognized terminal event).
|
|
408
409
|
*/
|
package/dist/types/types.d.ts
CHANGED
|
@@ -537,6 +537,8 @@ export interface AssistantMessage {
|
|
|
537
537
|
stopReason: StopReason;
|
|
538
538
|
stopDetails?: StopDetails | null;
|
|
539
539
|
errorMessage?: string;
|
|
540
|
+
/** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */
|
|
541
|
+
toolCallAbortMessages?: Record<string, string>;
|
|
540
542
|
/** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
|
|
541
543
|
errorStatus?: number;
|
|
542
544
|
/** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */
|
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.3.
|
|
4
|
+
"version": "16.3.12",
|
|
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.3.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.3.12",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.3.12",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.3.12",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -61,6 +61,41 @@ function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBloc
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function credentialBlockSnapshotsEqual(
|
|
65
|
+
left: readonly CredentialBlockSnapshot[] | undefined,
|
|
66
|
+
right: readonly CredentialBlockSnapshot[] | undefined,
|
|
67
|
+
): boolean {
|
|
68
|
+
const leftBlocks = left ?? [];
|
|
69
|
+
const rightBlocks = right ?? [];
|
|
70
|
+
if (leftBlocks.length !== rightBlocks.length) return false;
|
|
71
|
+
for (let index = 0; index < leftBlocks.length; index += 1) {
|
|
72
|
+
const leftBlock = leftBlocks[index]!;
|
|
73
|
+
const rightBlock = rightBlocks[index]!;
|
|
74
|
+
if (
|
|
75
|
+
leftBlock.providerKey !== rightBlock.providerKey ||
|
|
76
|
+
leftBlock.blockScope !== rightBlock.blockScope ||
|
|
77
|
+
leftBlock.blockedUntilMs !== rightBlock.blockedUntilMs
|
|
78
|
+
) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function snapshotBlocksChanged(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[]): boolean {
|
|
86
|
+
const previousBlocksById = new Map<number, readonly CredentialBlockSnapshot[] | undefined>();
|
|
87
|
+
for (const entry of previous) previousBlocksById.set(entry.id, entry.blocks);
|
|
88
|
+
for (const entry of next) {
|
|
89
|
+
const previousBlocks = previousBlocksById.get(entry.id);
|
|
90
|
+
if (!credentialBlockSnapshotsEqual(previousBlocks, entry.blocks)) return true;
|
|
91
|
+
previousBlocksById.delete(entry.id);
|
|
92
|
+
}
|
|
93
|
+
for (const previousBlocks of previousBlocksById.values()) {
|
|
94
|
+
if (previousBlocks && previousBlocks.length > 0) return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
function credentialEntryWithBlocks(
|
|
65
100
|
entry: AuthCredentialSnapshotEntry,
|
|
66
101
|
blocks: readonly CredentialBlockSnapshot[] | undefined,
|
|
@@ -173,6 +208,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
173
208
|
#cache: Map<string, CacheEntry> = new Map();
|
|
174
209
|
#usageCache?: UsageCacheEntry;
|
|
175
210
|
#usageInflight?: Promise<UsageReport[] | null>;
|
|
211
|
+
#usageCacheEpoch = 0;
|
|
176
212
|
#closed = false;
|
|
177
213
|
/**
|
|
178
214
|
* `true` once the SSE consumer received its first frame and hasn't dropped
|
|
@@ -202,10 +238,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
202
238
|
|
|
203
239
|
#applySnapshot(snapshot: SnapshotResponse, generation: number): void {
|
|
204
240
|
const nowMs = Date.now();
|
|
205
|
-
this.#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
};
|
|
241
|
+
const credentials = snapshot.credentials.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs));
|
|
242
|
+
if (snapshotBlocksChanged(this.#snapshot.credentials, credentials)) this.#invalidateUsageCache();
|
|
243
|
+
this.#snapshot = { ...snapshot, credentials };
|
|
209
244
|
this.#generation = generation;
|
|
210
245
|
this.#snapshotReceivedAt = nowMs;
|
|
211
246
|
const onSnapshot = this.#onSnapshot;
|
|
@@ -304,6 +339,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
304
339
|
): void {
|
|
305
340
|
const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
|
|
306
341
|
const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === incoming.id);
|
|
342
|
+
const previousBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
|
|
343
|
+
if (!credentialBlockSnapshotsEqual(previousBlocks, incoming.blocks)) this.#invalidateUsageCache();
|
|
307
344
|
const credentials =
|
|
308
345
|
index === -1
|
|
309
346
|
? [...this.#snapshot.credentials, incoming]
|
|
@@ -314,6 +351,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
314
351
|
}
|
|
315
352
|
|
|
316
353
|
#removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
|
|
354
|
+
const removed = this.#snapshot.credentials.find(entry => entry.id === id);
|
|
355
|
+
if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
|
|
317
356
|
const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
|
318
357
|
this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
|
|
319
358
|
this.#generation = generation;
|
|
@@ -376,6 +415,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
376
415
|
|
|
377
416
|
upsertCredentialBlock(block: StoredCredentialBlock): void {
|
|
378
417
|
this.#upsertSnapshotBlock(block);
|
|
418
|
+
this.#invalidateUsageCache();
|
|
379
419
|
const body = toCredentialBlockSnapshot(block);
|
|
380
420
|
void this.#client
|
|
381
421
|
.upsertCredentialBlock(block.credentialId, body)
|
|
@@ -394,6 +434,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
394
434
|
|
|
395
435
|
deleteCredentialBlocks(credentialId: number): void {
|
|
396
436
|
this.#deleteSnapshotBlocks(credentialId);
|
|
437
|
+
this.#invalidateUsageCache();
|
|
397
438
|
void this.#client
|
|
398
439
|
.deleteCredentialBlocks(credentialId)
|
|
399
440
|
.then(() => {
|
|
@@ -698,6 +739,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
698
739
|
}
|
|
699
740
|
}
|
|
700
741
|
|
|
742
|
+
#invalidateUsageCache(): void {
|
|
743
|
+
this.#usageCache = undefined;
|
|
744
|
+
this.#usageInflight = undefined;
|
|
745
|
+
this.#usageCacheEpoch += 1;
|
|
746
|
+
}
|
|
747
|
+
|
|
701
748
|
/**
|
|
702
749
|
* Store-level hook consumed by `AuthStorage` — routes refresh through the
|
|
703
750
|
* broker so the actual refresh token never leaves the broker host. Returns
|
|
@@ -834,9 +881,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
834
881
|
return Promise.resolve(cached.reports);
|
|
835
882
|
}
|
|
836
883
|
if (this.#usageInflight) return this.#usageInflight;
|
|
884
|
+
const epoch = this.#usageCacheEpoch;
|
|
837
885
|
const inflight = this.#client
|
|
838
886
|
.fetchUsage()
|
|
839
887
|
.then(body => {
|
|
888
|
+
if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
|
|
840
889
|
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
|
|
841
890
|
return body.reports;
|
|
842
891
|
})
|
|
@@ -845,11 +894,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
845
894
|
// Documented 15s TTL fallback: cache the null so sequential callers
|
|
846
895
|
// don't re-hit the broker while it's still down. See
|
|
847
896
|
// docs/auth-broker-gateway.md § "Client-side single-flight".
|
|
897
|
+
if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
|
|
848
898
|
this.#usageCache = { reports: null, fetchedAt: Date.now() };
|
|
849
899
|
return null;
|
|
850
900
|
})
|
|
851
901
|
.finally(() => {
|
|
852
|
-
this.#usageInflight = undefined;
|
|
902
|
+
if (this.#usageInflight === inflight) this.#usageInflight = undefined;
|
|
853
903
|
});
|
|
854
904
|
this.#usageInflight = inflight;
|
|
855
905
|
return inflight;
|
package/src/auth-retry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OAuthAccess } from "./auth-storage";
|
|
2
2
|
import * as AIError from "./error";
|
|
3
3
|
import { isAuthRetryableError } from "./error/auth-classify";
|
|
4
|
+
import { isUsageLimit } from "./error/flags";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Context passed to an {@link ApiKeyResolver} on each resolution attempt.
|
|
@@ -23,6 +24,8 @@ export interface ApiKeyResolveContext {
|
|
|
23
24
|
lastChance: boolean;
|
|
24
25
|
/** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
|
|
25
26
|
error: unknown;
|
|
27
|
+
/** Bearer used by the failed attempt, when the caller can expose it. */
|
|
28
|
+
previousKey?: string;
|
|
26
29
|
/** Caller cancel signal, threaded into any credential refresh / rotation work. */
|
|
27
30
|
signal?: AbortSignal;
|
|
28
31
|
}
|
|
@@ -87,9 +90,11 @@ export async function resolveRetryKey(
|
|
|
87
90
|
lastChance: boolean,
|
|
88
91
|
error: unknown,
|
|
89
92
|
signal?: AbortSignal,
|
|
93
|
+
previousKey?: string,
|
|
90
94
|
): Promise<string | undefined> {
|
|
91
95
|
try {
|
|
92
|
-
|
|
96
|
+
const rotateSibling = lastChance || (!lastChance && isUsageLimit(error));
|
|
97
|
+
return (await resolver({ lastChance: rotateSibling, error, signal, previousKey })) || undefined;
|
|
93
98
|
} catch {
|
|
94
99
|
return undefined;
|
|
95
100
|
}
|
|
@@ -136,7 +141,7 @@ export async function withAuth<T>(
|
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
for (let i = 0; i < AUTH_RETRY_STEPS.length; i++) {
|
|
139
|
-
const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal);
|
|
144
|
+
const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal, lastKey);
|
|
140
145
|
if (nextKey === undefined || nextKey === lastKey) continue;
|
|
141
146
|
lastKey = nextKey;
|
|
142
147
|
try {
|