@oh-my-pi/pi-ai 18.13.1 → 18.13.3

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +17 -18
  2. package/dist/types/auth/refresh.d.ts +1 -1
  3. package/dist/types/auth/sqlite-credential-store.d.ts +3 -1
  4. package/dist/types/auth/store.d.ts +13 -0
  5. package/dist/types/auth/types.d.ts +15 -0
  6. package/dist/types/auth-gateway/dispatch.d.ts +1 -1
  7. package/dist/types/auth-retry.d.ts +19 -0
  8. package/dist/types/error/flags.d.ts +1 -0
  9. package/dist/types/providers/google-types.d.ts +1 -0
  10. package/dist/types/providers/openai-completions.d.ts +13 -0
  11. package/dist/types/providers/transform-messages.d.ts +9 -0
  12. package/dist/types/utils/request-compression.d.ts +2 -0
  13. package/dist/types/utils/schema/json-schema-validator.d.ts +5 -8
  14. package/package.json +7 -7
  15. package/src/auth/cascade.ts +27 -18
  16. package/src/auth/oauth.ts +1 -0
  17. package/src/auth/refresh.ts +408 -52
  18. package/src/auth/select.ts +7 -1
  19. package/src/auth/sqlite-credential-store.ts +29 -1
  20. package/src/auth/store.ts +14 -0
  21. package/src/auth/types.ts +15 -0
  22. package/src/auth-gateway/dispatch.ts +11 -1
  23. package/src/auth-retry.ts +48 -1
  24. package/src/error/flags.ts +13 -0
  25. package/src/error/rate-limit.ts +67 -5
  26. package/src/error/retryable.ts +4 -1
  27. package/src/judgment/text.ts +1 -9
  28. package/src/providers/cowork-fetch.ts +0 -3
  29. package/src/providers/devin.ts +16 -7
  30. package/src/providers/google-gemini-cli.ts +10 -3
  31. package/src/providers/google-shared.ts +5 -2
  32. package/src/providers/google-types.ts +1 -1
  33. package/src/providers/openai-completions.ts +28 -9
  34. package/src/providers/transform-messages.ts +27 -7
  35. package/src/usage/github-copilot.ts +47 -16
  36. package/src/utils/request-compression.ts +1 -1
  37. package/src/utils/schema/json-schema-validator.ts +5 -8
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.13.3] - 2026-10-28
6
+
7
+ ### Changed
8
+
9
+ - Updated Bun engine requirement to ≥1.4.4
10
+
11
+ ## [18.13.2] - 2026-10-27
12
+
13
+ ### Fixed
14
+
15
+ - Fixed OAuth logins shared by several processes being disabled with `invalid_grant` when they refreshed at the same time or one of them quit mid-refresh ([#17128](https://github.com/can1357/oh-my-pi/pull/17128) by [@sjawhar](https://github.com/sjawhar))
16
+ - Fixed transient Google 429 rate-limit and capacity responses retrying in place without blocking or rotating credentials, while daily caps and zero-entitlement limits still rotate ([#17127](https://github.com/can1357/oh-my-pi/pull/17127) by [@GTanger](https://github.com/GTanger))
17
+ - Fixed Aliyun DashScope's multimodal gateway failing a turn on its intermittent `InvalidParameter` 400s for valid inline images ("provided URL does not appear to be valid", "Download multimodal file timed out"); they are now retried ([#17101](https://github.com/can1357/oh-my-pi/pull/17101) by [@shuairongzeng](https://github.com/shuairongzeng))
18
+ - Fixed a revoked or rejected GitHub Copilot credential continuing to show its last cached usage quota instead of being dropped ([#17081](https://github.com/can1357/oh-my-pi/issues/17081))
19
+ - Fixed Devin requests compressing the request body on the UI thread, which briefly froze the TUI on long conversations; requests under 1 KiB are now sent uncompressed
20
+
5
21
  ## [18.13.1] - 2026-10-27
6
22
 
7
23
  ### Fixed
@@ -2308,21 +2324,4 @@
2308
2324
 
2309
2325
  - Removed legacy GitLab Duo Workflow `chat` and `software_development` flow paths and the non-MCP action bridge in favor of the inline custom `ambient` flow.
2310
2326
 
2311
- ## [16.1.23] - 2026-06-26
2312
-
2313
- ### Added
2314
-
2315
- - Added a third streaming thinking-loop detection heuristic to catch "progress-lexicon stalls" where models endlessly reshuffle motivational filler without introducing new vocabulary or concrete technical references
2316
- - Added branded wordmark and logo animation to authentication flow
2317
- - Added a third streaming thinking-loop detection shape — a _progress-lexicon stall_ — alongside verbatim tail repetition and near-duplicate (trigram) segments. It catches reasoning-summarizer loops that reshuffle the same motivational filler ("just doing it, pushing ahead, maintaining momentum") into fresh word order every paragraph: word-trigrams never cluster, but a run of substantial segments that recycle the recent vocabulary and introduce no _new_ concrete reference (path / identifier / code-span) trips the guard. Summarizer title/heading lines (`**Bold Title**`, `## Heading`) are stripped before analysis so their ever-changing wording cannot mask the stall by inflating novelty. Calibrated against 537k real non-Gemini reasoning blocks (zero false positives at novelty floor 0.2 / run length 8; the real loop sustains runs of 10+).
2318
- - Added CoreWeave Serverless Inference provider login support via `COREWEAVE_API_KEY` and `WANDB_API_KEY` fallback.
2319
-
2320
- ### Changed
2321
-
2322
- - Redesigned the OAuth callback page (`oauth.html`) to match the oh-my-pi web brand language: OKLCH purple-tinted dark neutrals, magenta→iris→cyan brand gradient on the wordmark, frosted-glass card over an ascii grid backdrop, and a colored status halo around the success/error icon. All assets are inlined; the `__OAUTH_STATE__` injection contract and success/error JS logic are unchanged.
2323
-
2324
- ### Fixed
2325
-
2326
- - Fixed local llama.cpp (and any local OpenAI-compatible server rendering the Qwen3.6+ chat template) re-processing the full prompt every new user message even with `replayReasoningContent` enabled (#3541 follow-up to #3528). Sending `reasoning_content` alone wasn't enough: Qwen3's chat template strips `<think>...</think>` from any assistant turn whose index is `<= last_query_index`, so the moment a new user message (the user's next prompt, or the auto-learn capture-at-stop nudge) lands, every prior assistant turn becomes "older" and is re-rendered without the `<think>` block — diverging from the generation tokens still in the slot's KV cache. The chat-completions encoder now emits `preserve_thinking: true` for Qwen thinking dialects on local servers, route-split the same way the existing `enable_thinking` emission is: the `qwen` dialect rides the top-level field (llama.cpp's `--jinja` hook and Alibaba Cloud Model Studio's compatible-mode), the `qwen-chat-template` dialect (NVIDIA NIM, vLLM/SGLang's chat-template-kwargs path) rides only `chat_template_kwargs.preserve_thinking` because NIM's request schema is `additionalProperties: false` and rejects unknown top-level fields (#2299). The emission is hoisted above the `reasoning.enabled` gate so it fires for THREE cases the original gating missed: (1) runtime-discovered local Qwen models that ship with `reasoning: false` because the upstream `/v1/models` doesn't advertise the capability (same gotcha #3532 fixed for `replayReasoningContent`), (2) caller-disabled reasoning (`/think off`) — the kwarg is a history-rendering knob, not a per-turn thinking switch, and the slot still holds `<think>` tokens from earlier turns, and (3) forced-tool-choice / DeepSeek-style auto-disable. Qwen3.6+ then renders `<think>...</think>` for every assistant turn regardless of position, and the next-turn render matches the cached generation tokens. ([#3541](https://github.com/can1357/oh-my-pi/issues/3541))
2327
-
2328
- Older entries are archived in [packages/ai/CHANGELOG.md@917a36589039](https://github.com/can1357/oh-my-pi/blob/917a365890396071a959dfd2865a3538518ef717/packages/ai/CHANGELOG.md).
2327
+ Older entries are archived in [packages/ai/CHANGELOG.md@a36a1f252e23](https://github.com/can1357/oh-my-pi/blob/a36a1f252e23031accb25bacf2b9894605ba524f/packages/ai/CHANGELOG.md).
@@ -54,7 +54,7 @@ export declare class OAuthRefresher {
54
54
  * continuing.
55
55
  */
56
56
  disableDefinitiveFailure(provider: string, credentialId: number | undefined, attemptedCredential: OAuthCredential, index: number, errorMsg: string): Promise<"disabled" | "peer-rotated" | "cas-lost">;
57
- refresh(provider: Provider, credential: OAuthCredential, credentialId: number | undefined, signal?: AbortSignal, reason?: OAuthRefreshReason): Promise<OAuthCredentials>;
57
+ refresh(provider: Provider, credential: OAuthCredential, credentialId: number | undefined, signal?: AbortSignal, reason?: OAuthRefreshReason, rejectedAccess?: string): Promise<OAuthCredentials>;
58
58
  /**
59
59
  * Refresh the OAuth credential with the given id through a per-credential
60
60
  * single-flight. Concurrent callers for the same row await the same upstream
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { Database } from "bun:sqlite";
8
8
  import { isSqliteBusyError, isSqliteCorruptionError } from "@oh-my-pi/pi-utils";
9
- import type { AuthCredentialStore, CredentialRefreshLeaseFence } from "./store.js";
9
+ import type { AuthCredentialStore, CredentialRefreshLeaseFence, CredentialRefreshLeaseHolder } from "./store.js";
10
10
  import type { AuthCredential, DisabledCredentialSummary, OAuthCredential, StoredAuthCredential, StoredCredentialBlock } from "./types.js";
11
11
  import type { OAuthCredentials } from "../registry/oauth/types.js";
12
12
  import type { ClientUsageReport, ClientUsageSummary, UsageHistoryEntry, UsageHistoryQuery } from "../usage.js";
@@ -89,6 +89,8 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
89
89
  tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean;
90
90
  getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined;
91
91
  renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean;
92
+ getCredentialRefreshLeaseHolder(credentialId: number): CredentialRefreshLeaseHolder | undefined;
93
+ handBackCredentialRefreshLease(credentialId: number, owner: string, holder: CredentialRefreshLeaseHolder): boolean;
92
94
  releaseCredentialRefreshLease(credentialId: number, owner: string): void;
93
95
  recordUsageSnapshots(entries: UsageHistoryEntry[]): void;
94
96
  listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[];
@@ -96,6 +96,19 @@ export interface CredentialRefreshLeaseStore {
96
96
  getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined;
97
97
  releaseCredentialRefreshLease(credentialId: number, owner: string): void;
98
98
  renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean;
99
+ /** The lease row as stored, expired or not; `undefined` when no lease row exists. */
100
+ getCredentialRefreshLeaseHolder?(credentialId: number): CredentialRefreshLeaseHolder | undefined;
101
+ /**
102
+ * Give a lease this owner took back to the holder it replaced, with that
103
+ * holder's own expiry, so the holder can still renew it and save the token
104
+ * it may have exchanged. Returns false when `owner` no longer holds it.
105
+ */
106
+ handBackCredentialRefreshLease?(credentialId: number, owner: string, holder: CredentialRefreshLeaseHolder): boolean;
107
+ }
108
+ /** Owner and expiry of a stored refresh lease row. */
109
+ export interface CredentialRefreshLeaseHolder {
110
+ owner: string;
111
+ expiresAtMs: number;
99
112
  }
100
113
  /** Usage history and client-observed request accounting. */
101
114
  export interface UsageLedgerStore {
@@ -434,6 +434,13 @@ export type AuthApiKeyOptions = {
434
434
  forceRefresh?: boolean;
435
435
  /** Explicit provider-401 recovery; generic force refreshes leave this unset. */
436
436
  refreshReason?: OAuthRefreshReason;
437
+ /**
438
+ * The access token the provider rejected with a 401, when this resolve is
439
+ * that 401's recovery. A refresh adopts a stored token that differs from it
440
+ * instead of exchanging the refresh token again: with single-use refresh
441
+ * tokens every needless exchange revokes the token its peers are using.
442
+ */
443
+ rejectedAccessToken?: string;
437
444
  /** When false, select as `sessionId` would without recording the choice as that session's sticky credential. */
438
445
  recordAffinity?: boolean;
439
446
  };
@@ -555,6 +562,12 @@ export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCred
555
562
  observedCredential?: T;
556
563
  credentialFromRow: (credential: OAuthCredential) => T | undefined;
557
564
  forceRefresh?: boolean;
565
+ /**
566
+ * The access token a provider just rejected with a 401. A forced refresh
567
+ * adopts a still-fresh stored row whose token differs from it: another
568
+ * process rotated the row, and its token has not been rejected.
569
+ */
570
+ rejectedAccess?: string;
558
571
  canRefresh?: (credential: T) => boolean;
559
572
  refreshSkewMs?: number;
560
573
  signal?: AbortSignal;
@@ -963,6 +976,8 @@ export interface OAuthRefreshByIdOptions {
963
976
  * Local reuse still requires {@link reuseRecentMint}.
964
977
  */
965
978
  reason?: OAuthRefreshReason;
979
+ /** The access token a provider 401 rejected; see {@link AuthApiKeyOptions.rejectedAccessToken}. */
980
+ rejectedAccess?: string;
966
981
  }
967
982
  /** OAuth login, access, account identity, and refresh operations. */
968
983
  export interface OAuthApi {
@@ -1,4 +1,4 @@
1
- import type { ApiKeyResolver, ResolvedApiKey } from "../auth-retry.js";
1
+ import { type ApiKeyResolver, type ResolvedApiKey } from "../auth-retry.js";
2
2
  import type { AuthStorage } from "../auth-storage.js";
3
3
  import { type GatewayErrorClassification } from "../error/gateway.js";
4
4
  import type { Api, FetchImpl, Model, Usage } from "../types.js";
@@ -36,6 +36,13 @@ export interface ResolvedApiKey {
36
36
  apiKey: string;
37
37
  /** Durable row id of the credential that supplied this bearer, when known. */
38
38
  credentialId?: number;
39
+ /**
40
+ * The OAuth access token behind this bearer, when the bearer came from a
41
+ * stored OAuth credential. A provider whose API key wraps the token in a
42
+ * structured document makes the two differ, so a 401 recovery names this
43
+ * one as the token the provider rejected.
44
+ */
45
+ oauthAccess?: string;
39
46
  /**
40
47
  * Resolved after `LimitsApi.rotate` slept out a sibling's short block
41
48
  * (`afterSiblingWait`): the driver accepts this bearer even if the request
@@ -55,6 +62,18 @@ export declare function resolvedApiKeyBearer(resolved: ApiKeyResolution): string
55
62
  * resolvers (`KeyCascade.resolver`, coding-agent `createApiKeyResolver`).
56
63
  */
57
64
  export declare function markAfterSiblingWait(resolved: ApiKeyResolution): ApiKeyResolution;
65
+ /**
66
+ * Remembers which OAuth access token produced each bearer a resolver served,
67
+ * so a 401 recovery can name the token of the request that actually failed.
68
+ * A resolver serves many requests, and they fail out of order: the last
69
+ * resolution is not that request's identity. Used by every resolver that
70
+ * serves more than one request ({@link KeyCascade.resolver}, the coding
71
+ * agent's `createApiKeyResolver`, the auth gateway's resolver).
72
+ */
73
+ export declare function createOAuthProvenance(): {
74
+ record: (resolved: ApiKeyResolution) => void;
75
+ accessFor: (bearer: string | undefined) => string | undefined;
76
+ };
58
77
  /** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
59
78
  export type ApiKey = string | ApiKeyResolver;
60
79
  /** Keyless-provider credential marker; transports must not send it in authentication headers. */
@@ -94,6 +94,7 @@ export declare function isImmutableLatestThinkingText(text: string): boolean;
94
94
  * output, so callers strip {@link Flag.Transient} when this matches.
95
95
  */
96
96
  export declare const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN: RegExp;
97
+ export declare const ALIYUN_MM_GATEWAY_PATTERN: RegExp;
97
98
  /** Whether an OAuth refresh error message means the grant is definitively dead. */
98
99
  export declare function isOAuthExpiry(errorMessage: string): boolean;
99
100
  export declare function create(...flags: number[]): number;
@@ -157,5 +157,6 @@ export interface GenerateContentResponse {
157
157
  code?: number;
158
158
  message?: string;
159
159
  status?: string;
160
+ details?: unknown[];
160
161
  };
161
162
  }
@@ -3,6 +3,8 @@ import type { AssistantMessage, Context, Model, ServiceTier, StreamFunction, Str
3
3
  import type { ChatCompletionMessageParam } from "./openai-chat-wire.js";
4
4
  import { type OpenAIPromptCacheOptions } from "./openai-shared.js";
5
5
  export { applyOpenRouterRoutingVariant } from "./openai-shared.js";
6
+ /** Whether a thinking block's signature is a raw LiteLLM (Anthropic) one, not a reasoning field name. */
7
+ export declare function isLiteLLMThinkingSignature(signature: string | undefined): signature is string;
6
8
  /**
7
9
  * Identify "real progress" stream chunks vs. keepalives, role-only preambles,
8
10
  * and empty `{choices:[]}` no-ops emitted by some OpenAI-compatible endpoints.
@@ -43,4 +45,15 @@ export interface OpenAICompletionsOptions extends StreamOptions {
43
45
  */
44
46
  export declare const streamOpenAICompletions: StreamFunction<"openai-completions">;
45
47
  export declare function parseChunkUsage(rawUsage: object, model: Model<"openai-completions">, premiumRequests: number | undefined, timestamp?: number, cachedTokensHeader?: number): AssistantMessage["usage"];
48
+ /**
49
+ * Whether every chat-completions request to `model` drops the thinking text of
50
+ * its own prior turns: neither the base compat nor its `whenThinking` view
51
+ * sends it. Blocks with a raw LiteLLM signature ({@link isLiteLLMThinkingSignature})
52
+ * still replay in `thinking_blocks`.
53
+ */
54
+ export declare function completionsDropsOwnThinking(model: {
55
+ api?: string;
56
+ reasoning?: boolean;
57
+ compat?: Model["compat"];
58
+ } | null | undefined): boolean;
46
59
  export declare function convertMessages(model: Model<"openai-completions">, context: Context, compat: ResolvedOpenAICompat): ChatCompletionMessageParam[];
@@ -1,3 +1,4 @@
1
+ import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/types";
1
2
  import type { Api, AssistantMessage, Message, Model } from "../types.js";
2
3
  /**
3
4
  * Maximum tool-call id length the strictest replay provider accepts.
@@ -54,6 +55,14 @@ export declare function isSurvivingAssistant(msg: AssistantMessage, model?: Mode
54
55
  export declare function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages">;
55
56
  /** Whether `turn` came from `model` itself: its own turns replay their native reasoning unchanged. */
56
57
  export declare function isSameModelTurn(turn: Pick<AssistantMessage, "provider" | "api" | "model">, model: Pick<Model, "provider" | "api" | "id">): boolean;
58
+ /**
59
+ * Whether chat completions (`convertMessages`) writes the model's own past
60
+ * thinking under `compat`: as Mistral content parts, demoted text, or a
61
+ * reasoning field. Otherwise it drops it.
62
+ */
63
+ export declare function completionsSendsOwnThinking(model: {
64
+ reasoning?: boolean;
65
+ }, compat: Pick<ResolvedOpenAICompat, "mistralReasoningContentParts" | "requiresThinkingAsText" | "requiresReasoningContentForToolCalls" | "thinkingFormat" | "replayReasoningContent">): boolean;
57
66
  /**
58
67
  * Normalize tool call ID for cross-provider compatibility.
59
68
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -1,4 +1,6 @@
1
1
  import type { FetchImpl, Model } from "../types.js";
2
+ /** Bodies below one TCP segment gain nothing from compression; skip them. */
3
+ export declare const MIN_COMPRESSED_BODY_BYTES = 1024;
2
4
  /**
3
5
  * Compresses request bodies from {@link model} to its provider's own endpoint
4
6
  * when its catalog rules declare encodings the endpoint decodes
@@ -28,14 +28,11 @@ export declare function getTagSelectedUnionBranch(branches: readonly unknown[],
28
28
  * schemas asking about the same name keep separate answers, and one schema
29
29
  * asked about two names keeps two answers.
30
30
  *
31
- * The root identity covers the graph because the schema graphs reaching this
32
- * function are already treated as immutable for the lifetime of their
33
- * identity: `toolWireSchema` memoizes the wire schema against the tool's
34
- * parameters, and `stripSchemaDescriptions`, `tryEnforceStrictSchema` and
35
- * `memoizedInjectIntentIntoSchema` already memoize derived values against
36
- * that same wire-schema object. A graph mutated in place after production
37
- * would already hand those three a stale answer; this cache adds no new
38
- * assumption beyond the ones the schema pipeline already relies on.
31
+ * The root identity covers the graph because callers must not mutate a schema
32
+ * after its first query: wire schemas are covered by that same rule (their
33
+ * derived values memoize against the same identity), and raw MCP
34
+ * `inputSchema` and eval-tool `parameters` graphs reach this function with no
35
+ * other stamp on them, so a new identity is what resets the memo for those.
39
36
  */
40
37
  export declare function schemaDefinesProperty(schema: unknown, key: string): boolean;
41
38
  export declare function validateJsonSchemaValue(schema: unknown, value: unknown, root?: unknown): JsonSchemaValidationResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.13.1",
3
+ "version": "18.13.3",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,16 +155,16 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.13.1",
159
- "@oh-my-pi/pi-catalog": "18.13.1",
160
- "@oh-my-pi/pi-natives": "18.13.1",
161
- "@oh-my-pi/pi-utils": "18.13.1",
162
- "@oh-my-pi/pi-wire": "18.13.1"
158
+ "@oh-my-pi/omptype": "18.13.3",
159
+ "@oh-my-pi/pi-catalog": "18.13.3",
160
+ "@oh-my-pi/pi-natives": "18.13.3",
161
+ "@oh-my-pi/pi-utils": "18.13.3",
162
+ "@oh-my-pi/pi-wire": "18.13.3"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
166
166
  },
167
167
  "engines": {
168
- "bun": ">=1.3.14"
168
+ "bun": ">=1.4.4"
169
169
  }
170
170
  }
@@ -1,6 +1,6 @@
1
1
  import { authPolicyFor } from "@oh-my-pi/pi-catalog/compat/auth";
2
2
  import { $env, $envExact, logger } from "@oh-my-pi/pi-utils";
3
- import { type ApiKeyResolver, markAfterSiblingWait, type ResolvedApiKey } from "../auth-retry";
3
+ import { type ApiKeyResolver, createOAuthProvenance, markAfterSiblingWait, type ResolvedApiKey } from "../auth-retry";
4
4
  import { getEnvApiKey, getEnvApiKeyName } from "../env-api-key";
5
5
  import * as AIError from "../error";
6
6
  import { isUsageLimitOutcome } from "../error/rate-limit";
@@ -332,11 +332,19 @@ export class KeyCascade implements KeysApi {
332
332
  ): Promise<ResolvedApiKey | undefined> {
333
333
  let credentialId: number | undefined;
334
334
  let oauthIdentity: OAuthRequestIdentity | undefined;
335
- const apiKey = await this.#resolve(provider, sessionId, options, (id, identity) => {
335
+ let oauthAccess: string | undefined;
336
+ const apiKey = await this.#resolve(provider, sessionId, options, (id, identity, access) => {
336
337
  credentialId = id;
337
338
  oauthIdentity = identity;
339
+ oauthAccess = access;
338
340
  });
339
- return apiKey === undefined ? undefined : { apiKey, credentialId, ...(oauthIdentity ? { oauthIdentity } : {}) };
341
+ if (apiKey === undefined) return undefined;
342
+ return {
343
+ apiKey,
344
+ credentialId,
345
+ ...(oauthAccess ? { oauthAccess } : {}),
346
+ ...(oauthIdentity ? { oauthIdentity } : {}),
347
+ };
340
348
  }
341
349
 
342
350
  /**
@@ -365,7 +373,7 @@ export class KeyCascade implements KeysApi {
365
373
  provider: string,
366
374
  sessionId?: string,
367
375
  options?: AuthApiKeyOptions,
368
- onCredentialId?: (id: number, identity?: OAuthRequestIdentity) => void,
376
+ onCredentialId?: (id: number, identity?: OAuthRequestIdentity, oauthAccess?: string) => void,
369
377
  ): Promise<string | undefined> {
370
378
  const restricted = this.#deps.affinity.isRestricted(provider, sessionId);
371
379
  // Runtime override takes highest priority
@@ -405,7 +413,8 @@ export class KeyCascade implements KeysApi {
405
413
  if (oauthResolved) {
406
414
  if (onCredentialId && oauthResolved.credentialId !== undefined) {
407
415
  const { orgId, region, inferenceRegion } = oauthResolved.credential;
408
- onCredentialId(oauthResolved.credentialId, { orgId, region, inferenceRegion });
416
+ const identity = { orgId, region, inferenceRegion };
417
+ onCredentialId(oauthResolved.credentialId, identity, oauthResolved.credential.access);
409
418
  }
410
419
  return oauthResolved.apiKey;
411
420
  }
@@ -476,13 +485,18 @@ export class KeyCascade implements KeysApi {
476
485
  */
477
486
  resolver(provider: string, options?: { sessionId?: string; baseUrl?: string; modelId?: string }): ApiKeyResolver {
478
487
  const { sessionId, baseUrl, modelId } = options ?? {};
488
+ // One resolver serves many requests, and they fail out of order, so the
489
+ // token a 401 rejected is looked up from the bearer that request sent.
490
+ const provenance = createOAuthProvenance();
491
+ const resolve = async (extra: AuthApiKeyOptions): Promise<ResolvedApiKey | undefined> => {
492
+ const resolved = await this.getWithCredential(provider, sessionId, { baseUrl, modelId, ...extra });
493
+ provenance.record(resolved);
494
+ return resolved;
495
+ };
479
496
  return async ({ lastChance, error, signal, previousKey }) => {
497
+ const rejectedAccessToken = provenance.accessFor(previousKey);
480
498
  if (error === undefined) {
481
- return this.getWithCredential(provider, sessionId, {
482
- baseUrl,
483
- modelId,
484
- signal,
485
- });
499
+ return resolve({ signal });
486
500
  }
487
501
  if (lastChance) {
488
502
  const rotation = await this.#deps.rotate(provider, sessionId, {
@@ -499,18 +513,13 @@ export class KeyCascade implements KeysApi {
499
513
  // because a peer may have refreshed the failed bearer.
500
514
  if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) return undefined;
501
515
  }
502
- const resolved = await this.getWithCredential(provider, sessionId, {
503
- baseUrl,
504
- modelId,
505
- signal,
506
- });
516
+ const resolved = await resolve({ signal });
507
517
  return rotation.afterSiblingWait ? markAfterSiblingWait(resolved) : resolved;
508
518
  }
509
- return this.getWithCredential(provider, sessionId, {
510
- baseUrl,
511
- modelId,
519
+ return resolve({
512
520
  forceRefresh: true,
513
521
  refreshReason: AIError.status(error) === 401 ? "auth-recovery" : undefined,
522
+ rejectedAccessToken,
514
523
  signal,
515
524
  });
516
525
  };
package/src/auth/oauth.ts CHANGED
@@ -185,6 +185,7 @@ export class OAuthAccounts implements OAuthApi {
185
185
  await this.refresh(selection.credentialId, options.signal, {
186
186
  reuseRecentMint: options.refreshReason === "auth-recovery",
187
187
  reason: options.refreshReason,
188
+ rejectedAccess: options.rejectedAccessToken,
188
189
  });
189
190
  // The refresh re-lists rows, so a concurrently removed row can shift this one's position.
190
191
  index = this.#deps.pool.entries(provider).findIndex(entry => entry.id === selection.credentialId);