@oh-my-pi/pi-ai 18.8.4 → 18.8.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,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.6] - 2026-10-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Google Gemini and Cloud Code Assist (Antigravity) requests failing when tool schemas contain unsupported JSON Schema keywords or fields that allow multiple types.
10
+ - Fixed auth broker account selection and usage-limit enforcement to consistently use the selected account’s quota when multiple accounts are present.
11
+ - Fixed Anthropic-family model streaming so encoded marker tokens are decoded correctly in text, tool-call updates, partial messages, and completed tool calls.
12
+
13
+ ## [18.8.5] - 2026-10-08
14
+
15
+ ### Added
16
+
17
+ - `oauth.refresh(id, signal, { reason: "auth-recovery" })` forwards provider-401 recovery intent to a delegated (auth broker) refresh, and `AuthStorageOptions.refreshOAuthCredentialMints` marks a `refreshOAuthCredential` hook that exchanges tokens itself so its tokens are reused for auth recovery ([#14752](https://github.com/can1357/oh-my-pi/pull/14752) by [@will-bogusz](https://github.com/will-bogusz))
18
+ - `SessionsApi.inherit` accepts an optional filter, called with each provider and whether the source's affinity is an explicit user pin, to copy only the affinities it accepts ([#14749](https://github.com/can1357/oh-my-pi/pull/14749) by [@will-bogusz](https://github.com/will-bogusz))
19
+
20
+ ### Changed
21
+
22
+ - `AuthApiKeyOptions.accountIds` also matches the login email, or else the project id, of credentials that carry no account id (see `oauthAccountKey`), so Antigravity requests prefer accounts that serve the requested model ([#14924](https://github.com/can1357/oh-my-pi/issues/14924)).
23
+
24
+ ### Fixed
25
+
26
+ - Fixed accounts sitting exactly at their `reservePct` (e.g. 70% used with a 30% reserve) still being picked and reported healthy instead of being held in reserve ([#14765](https://github.com/can1357/oh-my-pi/pull/14765) by [@will-bogusz](https://github.com/will-bogusz))
27
+ - Fixed `oauth.accessById` with `forceRefresh` returning the stored token unchanged while it was still valid; it now re-mints that one account (through the auth broker when configured) and returns that account's token even if another row is removed meanwhile ([#14752](https://github.com/can1357/oh-my-pi/pull/14752) by [@will-bogusz](https://github.com/will-bogusz))
28
+ - Fixed `omp -p` and other short-lived auth-broker clients missing from `omp usage clients`: usage still waiting for the 10-second report batch is now sent to the broker before the process exits ([#14899](https://github.com/can1357/oh-my-pi/pull/14899) by [@will-bogusz](https://github.com/will-bogusz))
29
+ - Fixed Cursor web fetches that were cut off by a dropped connection disappearing from resumed and rebuilt sessions; they now show as interrupted ([#14819](https://github.com/can1357/oh-my-pi/pull/14819) by [@jchanghong023](https://github.com/jchanghong023))
30
+ - Anthropic hosted web search can honor custom providers' OAuth-style request shaping and configured headers consistently with conversations ([#14919](https://github.com/can1357/oh-my-pi/pull/14919) by [@farnoy](https://github.com/farnoy))
31
+
5
32
  ## [18.8.4] - 2026-10-08
6
33
 
7
34
  ### Breaking Changes
@@ -2314,19 +2341,4 @@
2314
2341
  - Fixed OpenAI-compatible Ollama completions that return empty `finish_reason:length` after filling `num_ctx` so they surface an actionable context-window error instead of an empty length stop. ([#2774](https://github.com/can1357/oh-my-pi/issues/2774))
2315
2342
  - Fixed Codex browser login issuing credentials for the `opencode` OAuth originator while OMP requests identify as `pi`, which could make the first authenticated Codex request return 401 ([#2696](https://github.com/can1357/oh-my-pi/issues/2696)).
2316
2343
 
2317
- ## [16.0.1] - 2026-06-15
2318
-
2319
- ### Added
2320
-
2321
- - Added Umans AI Coding Plan API-key login support and `UMANS_AI_CODING_PLAN_API_KEY` environment fallback ([#2636](https://github.com/can1357/oh-my-pi/pull/2636) by [@oldschoola](https://github.com/oldschoola)).
2322
-
2323
- ### Fixed
2324
-
2325
- - Fixed OpenAI Responses, Azure OpenAI Responses, and Codex Responses providers ignoring async `onPayload` replacement bodies. Provider payload hooks can now transform the actual request body sent upstream, matching the Anthropic/Gemini replacement contract.
2326
- - Fixed OpenAI-compatible chat-completions streams that send object-shaped tool arguments in fragments by deep-merging nested objects and task arrays instead of replacing earlier chunks. ([#2617](https://github.com/can1357/oh-my-pi/issues/2617))
2327
- - Fixed OpenAI Responses strict-mode tool schema normalization for nullable enum MCP parameters so enum constraints are distributed to matching `anyOf` branches instead of being copied onto the `null` branch. ([#1835](https://github.com/can1357/oh-my-pi/issues/1835))
2328
- - Fixed Cursor provider formatting tool errors with the same `[Tool Result]` prefix as successful results, causing Composer models to misinterpret error messages (e.g. "Pattern must not be empty") as directives over long conversations. Errors now use a `[Tool Error]` prefix so the model can distinguish failures from successes in the prompt history. ([#1853](https://github.com/can1357/oh-my-pi/pull/1853))
2329
- - Fixed `validateToolArguments` silently accepting JSON-encoded array strings (e.g. `'["a","b"]'`) against `union(string, array<string>)` schemas — providers that double-serialize tool-call arguments (Z.AI / GLM) caused tools like `search` to receive the literal `["a","b"]` as a single path, producing zero matches (single element) or glob parse errors (multi-element). A new pre-validation pass parses JSON-array-shaped strings when the schema explicitly accepts both shapes. ([#1788](https://github.com/can1357/oh-my-pi/issues/1788))
2330
- - Fixed Anthropic thinking summaries that arrive wrapped in literal `<thinking>` tags so advisor/raw transcript dumps do not render nested thinking tags ([#2695](https://github.com/can1357/oh-my-pi/issues/2695)).
2331
-
2332
- Older entries are archived in [packages/ai/CHANGELOG.md@d22e34333695](https://github.com/can1357/oh-my-pi/blob/d22e34333695d3dd9de7741ff45903681dba4606/packages/ai/CHANGELOG.md).
2344
+ Older entries are archived in [packages/ai/CHANGELOG.md@0dd6aff5f282](https://github.com/can1357/oh-my-pi/blob/0dd6aff5f2821aec1e5b54c6a458e323667a949b/packages/ai/CHANGELOG.md).
@@ -79,14 +79,15 @@ export declare class SessionAffinity implements SessionsApi {
79
79
  restoredAtMs?: number;
80
80
  }): boolean;
81
81
  /**
82
- * Copy every stored credential affinity from one live session to another.
82
+ * Copy stored credential affinities from one live session to another.
83
83
  *
84
84
  * The target receives its own sticky entries, so request resolution, usage
85
85
  * blocking, credential rotation, metadata, and persisted pins all continue
86
86
  * through the target session id without retaining a live dependency on the
87
- * source session.
87
+ * source session. `include` limits the copy to the providers it accepts,
88
+ * given whether the source's affinity is an explicit user pin.
88
89
  */
89
- inherit(sourceSessionId: string, targetSessionId: string): number;
90
+ inherit(sourceSessionId: string, targetSessionId: string, include?: (provider: string, explicit: boolean) => boolean): number;
90
91
  /**
91
92
  * Release a session's sticky credential so its next `KeyCascade.get` call
92
93
  * re-runs native pool ranking. This never blocks or penalizes the released
@@ -67,9 +67,10 @@ export declare class OAuthAccounts implements OAuthApi {
67
67
  * Resolve one stored OAuth credential by its durable storage row id.
68
68
  *
69
69
  * Unlike the normal session resolver, this method never ranks, rotates, or
70
- * falls back to sibling credentials. A forced refresh re-mints only the
71
- * requested row, preserving exact-account affinity for operations whose
72
- * provenance and policy boundary are tied to one workspace.
70
+ * falls back to sibling credentials. A generic forced refresh re-mints only the
71
+ * requested row; with `refreshReason: "auth-recovery"` it may instead reuse a
72
+ * still-usable recent mint of that row. Either way exact-account affinity holds
73
+ * for operations whose provenance and policy boundary are tied to one workspace.
73
74
  *
74
75
  * Returns `undefined` when the row does not exist for `provider` or an
75
76
  * explicit runtime/config API-key override suppresses OAuth.
@@ -32,6 +32,7 @@ export interface OAuthRefresherDeps {
32
32
  pool: CredentialPool;
33
33
  policies: AccountPolicies;
34
34
  override?: AuthStorageOptions["refreshOAuthCredential"];
35
+ overrideMints?: AuthStorageOptions["refreshOAuthCredentialMints"];
35
36
  }
36
37
  /** Single-flighted, lease-guarded OAuth refresh with compare-and-set persistence. */
37
38
  export declare class OAuthRefresher {
@@ -8,7 +8,7 @@ import { type CredentialPool } from "./pool.js";
8
8
  import { type ApiKeySelection, type OAuthSelection } from "./rank.js";
9
9
  import { type OAuthRefresher } from "./refresh.js";
10
10
  import type { AuthCredentialStore } from "./store.js";
11
- import type { ApiKeyCredential, AuthApiKeyOptions, AuthCredential, OAuthCredential } from "./types.js";
11
+ import { type ApiKeyCredential, type AuthApiKeyOptions, type AuthCredential, type OAuthCredential } from "./types.js";
12
12
  import type { UsageService } from "./usage.js";
13
13
  /** Temporary block after a transient OAuth refresh failure. */
14
14
  export declare const OAUTH_REFRESH_FAILURE_BACKOFF_MS: number;
@@ -316,6 +316,12 @@ export type AuthStorageOptions = {
316
316
  * the actual refresh token never leaves the broker.
317
317
  */
318
318
  refreshOAuthCredential?: (provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal, reason?: OAuthRefreshReason) => Promise<OAuthCredentials>;
319
+ /**
320
+ * Set when {@link refreshOAuthCredential} exchanges the refresh token with the
321
+ * provider itself instead of delegating (e.g. to a broker), so its tokens count
322
+ * as this process's mints for auth-recovery reuse.
323
+ */
324
+ refreshOAuthCredentialMints?: boolean;
319
325
  /**
320
326
  * Human-readable description of the credential store backing this
321
327
  * AuthStorage instance. Surfaced through {@link AuthStorage.keys.describe}
@@ -409,7 +415,7 @@ export interface ModelUsageHealthOptions {
409
415
  export type AuthApiKeyOptions = {
410
416
  baseUrl?: string;
411
417
  modelId?: string;
412
- /** Provider account ids known to serve `modelId` from multi-account discovery; OAuth selection prefers them and tries other accounts only as a last resort. */
418
+ /** {@link oauthAccountKey}s of accounts known to serve `modelId` from multi-account discovery; OAuth selection prefers them and tries other accounts only as a last resort. */
413
419
  accountIds?: readonly string[];
414
420
  /**
415
421
  * Caller's cancel signal. Threaded into any broker-bound OAuth refresh so
@@ -429,6 +435,19 @@ export type AuthApiKeyOptions = {
429
435
  /** When false, select as `sessionId` would without recording the choice as that session's sticky credential. */
430
436
  recordAffinity?: boolean;
431
437
  };
438
+ /**
439
+ * Identity that multi-account discovery records in `Model.accountAccess` and
440
+ * that {@link AuthApiKeyOptions.accountIds} selection matches: the provider
441
+ * account id (Codex `chatgpt_account_id`), else the login email for providers
442
+ * whose credentials carry no account id (Antigravity), else the project id for
443
+ * a login whose email lookup failed. Same precedence as the credential store's
444
+ * identity key.
445
+ */
446
+ export declare function oauthAccountKey(identity: {
447
+ accountId?: string;
448
+ email?: string;
449
+ projectId?: string;
450
+ }): string | undefined;
432
451
  /** Non-secret identity bound to the OAuth credential selected for one request attempt. */
433
452
  export interface OAuthRequestIdentity {
434
453
  orgId?: string;
@@ -917,7 +936,7 @@ export interface KeysApi {
917
936
  modelId?: string;
918
937
  }): ApiKeyResolver;
919
938
  }
920
- /** Controls whether a row-id refresh may reuse a token minted by this refresher. */
939
+ /** Controls whether a row-id refresh may reuse a recently minted token. */
921
940
  export interface OAuthRefreshByIdOptions {
922
941
  /**
923
942
  * Return the stored credential when it still holds a fresh access token this
@@ -925,6 +944,12 @@ export interface OAuthRefreshByIdOptions {
925
944
  * refresh tokens repeatedly when a provider rejects every valid bearer.
926
945
  */
927
946
  reuseRecentMint?: boolean;
947
+ /**
948
+ * Provider-401 recovery intent. Forwarded to a delegated refresh (an auth
949
+ * broker), which may then return a token it minted in the last five minutes.
950
+ * Local reuse still requires {@link reuseRecentMint}.
951
+ */
952
+ reason?: OAuthRefreshReason;
928
953
  }
929
954
  /** OAuth login, access, account identity, and refresh operations. */
930
955
  export interface OAuthApi {
@@ -966,9 +991,10 @@ export interface OAuthApi {
966
991
  * Resolve one stored OAuth credential by its durable storage row id.
967
992
  *
968
993
  * Unlike the normal session resolver, this method never ranks, rotates, or
969
- * falls back to sibling credentials. A forced refresh re-mints only the
970
- * requested row, preserving exact-account affinity for operations whose
971
- * provenance and policy boundary are tied to one workspace.
994
+ * falls back to sibling credentials. A generic forced refresh re-mints only the
995
+ * requested row; with `refreshReason: "auth-recovery"` it may instead reuse a
996
+ * still-usable recent mint of that row. Either way exact-account affinity holds
997
+ * for operations whose provenance and policy boundary are tied to one workspace.
972
998
  *
973
999
  * Returns `undefined` when the row does not exist for `provider` or an
974
1000
  * explicit runtime/config API-key override suppresses OAuth.
@@ -1002,8 +1028,9 @@ export interface OAuthApi {
1002
1028
  * Refresh the OAuth credential with the given id through a per-credential
1003
1029
  * single-flight. Concurrent callers for the same row await the same upstream
1004
1030
  * refresh attempt, which is required for providers that rotate refresh tokens
1005
- * on every successful refresh. Mints unconditionally unless
1006
- * {@link OAuthRefreshByIdOptions.reuseRecentMint} is set.
1031
+ * on every successful refresh. Mints unless {@link OAuthRefreshByIdOptions.reuseRecentMint}
1032
+ * finds a recent local mint, or a delegated refresh given
1033
+ * {@link OAuthRefreshByIdOptions.reason} `"auth-recovery"` returns its recent mint.
1007
1034
  */
1008
1035
  refresh(id: number, signal?: AbortSignal, options?: OAuthRefreshByIdOptions): Promise<AuthCredentialSnapshotEntry>;
1009
1036
  /**
@@ -1035,14 +1062,15 @@ export interface SessionsApi {
1035
1062
  restoredAtMs?: number;
1036
1063
  }): boolean;
1037
1064
  /**
1038
- * Copy every stored credential affinity from one live session to another.
1065
+ * Copy stored credential affinities from one live session to another.
1039
1066
  *
1040
1067
  * The target receives its own sticky entries, so request resolution, usage
1041
1068
  * blocking, credential rotation, metadata, and persisted pins all continue
1042
1069
  * through the target session id without retaining a live dependency on the
1043
- * source session.
1070
+ * source session. `include` limits the copy to the providers it accepts,
1071
+ * given whether the source's affinity is an explicit user pin.
1044
1072
  */
1045
- inherit(sourceSessionId: string, targetSessionId: string): number;
1073
+ inherit(sourceSessionId: string, targetSessionId: string, include?: (provider: string, explicit: boolean) => boolean): number;
1046
1074
  /**
1047
1075
  * Restrict one session's credentials for `provider` to the OAuth accounts
1048
1076
  * whose identity key (`email:<address>|org:<id>` for org-scoped providers;
@@ -149,9 +149,10 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
149
149
  ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean;
150
150
  /**
151
151
  * Fold locally observed request usage into the pending report and schedule
152
- * a flush. One `POST /v1/usage/observed` at most per flush interval; on
153
- * failure the batch is retained and retried with the next flush. A 404
154
- * (pre-endpoint broker) disables reporting for the life of this store.
152
+ * a flush. One `POST /v1/usage/observed` at most per flush interval, plus a
153
+ * final one when the process exits; on failure the batch is retained and
154
+ * retried with the next flush. A 404 (pre-endpoint broker) disables
155
+ * reporting for the life of this store.
155
156
  *
156
157
  * `client` overrides the reporting identity — the auth-gateway attributes
157
158
  * each request to the originating install/app instead of the gateway host.
@@ -169,6 +169,12 @@ export type AnthropicClientOptionsResult = {
169
169
  * omitted there.
170
170
  */
171
171
  export declare function resolveAnthropicCustomHeadersForBaseUrl(baseUrl: string | undefined): Record<string, string> | undefined;
172
+ /**
173
+ * Merge headers case-insensitively; later sources win and preserve their key casing.
174
+ * Undefined sources are ignored. Differently cased duplicates are removed so the
175
+ * Headers constructor cannot join competing values comma-separated on the wire.
176
+ */
177
+ export declare function mergeHeaders(...headerSources: (Record<string, string> | undefined)[]): Record<string, string>;
172
178
  export type AnthropicUsageLike = {
173
179
  cache_creation?: {
174
180
  ephemeral_5m_input_tokens?: number | null;
@@ -169,13 +169,13 @@ export declare function emptyGrepPatternRejection(pattern: string | undefined, g
169
169
  * so reparsing unconditionally would erase the arguments of every such block
170
170
  * caught open by a truncated stream.
171
171
  *
172
- * Server-owned blocks are also paired here. `connect-scm` and `todo` are
173
- * stamped {@link kCursorExecResolved} the moment they open, so `agent-loop.ts`
174
- * synthesizes no placeholder for them and only their `toolCallCompleted` frame
175
- * pairs a result. A transport that closes before that frame would leave the
176
- * call unpaired, and `buildSessionContext` strips a dangling call from every
177
- * rebuilt transcript — the interaction disappears. An interrupted result is
178
- * emitted instead.
172
+ * Server-owned blocks are also paired here. `connect-scm`, `todo` and
173
+ * `web-fetch` are stamped {@link kCursorExecResolved} the moment they open,
174
+ * so `agent-loop.ts` synthesizes no placeholder for them and only their
175
+ * `toolCallCompleted` frame pairs a result. A transport that closes before
176
+ * that frame would leave the call unpaired, and `buildSessionContext` strips
177
+ * a dangling call from every rebuilt transcript — the interaction disappears.
178
+ * An interrupted result is emitted instead.
179
179
  *
180
180
  * MCP blocks are excluded even when resolved: the exec dispatch that marked
181
181
  * them owns their result, and `drainInFlightDispatches` awaits it before this
@@ -142,6 +142,8 @@ export interface UsageReport {
142
142
  * explicit fraction > used/limit > percent-unit used > inverted remaining.
143
143
  */
144
144
  export declare function resolveUsedFraction(limit: UsageLimit): number | undefined;
145
+ /** Whether remaining quota is at or below the reserve (both 0..1 fractions). */
146
+ export declare function isWithinUsageReserve(remainingFraction: number, reserveFraction: number): boolean;
145
147
  /**
146
148
  * One recorded usage-limit snapshot: a single limit window of one account at
147
149
  * a point in time. The usage cache itself is latest-snapshot-only; history
@@ -9,6 +9,11 @@ export declare function resolveAnthropicBaseUrlFromEnv(): string | undefined;
9
9
  * Checks if a token is an OAuth token by looking for sk-ant-oat prefix.
10
10
  */
11
11
  export declare function isOAuthToken(apiKey: string): boolean;
12
+ /** Optional request-shaping overrides for an already-resolved Anthropic credential. */
13
+ export interface AnthropicAuthOptions {
14
+ /** Override token detection; false explicitly disables OAuth-style request shaping. */
15
+ isOAuth?: boolean;
16
+ }
12
17
  /**
13
18
  * Build an {@link AnthropicAuthConfig} from an already-resolved API key.
14
19
  *
@@ -17,18 +22,27 @@ export declare function isOAuthToken(apiKey: string): boolean;
17
22
  * env-derived base; pass `undefined` to fall back to FOUNDRY/ANTHROPIC env
18
23
  * resolution and finally `DEFAULT_BASE_URL`.
19
24
  *
20
- * `isOAuth` is derived from the token prefix so the helper stays pure: callers
21
- * never have to thread the OAuth flag through their own resolution logic.
25
+ * `options.isOAuth` defaults to token detection. Pass the selected model's
26
+ * `isOAuth` to match the streaming path, which lets the model override the
27
+ * prefix (custom `anthropic-messages` providers default to OAuth shaping).
22
28
  */
23
- export declare function buildAnthropicAuthConfig(apiKey: string, baseUrl?: string): AnthropicAuthConfig;
29
+ export declare function buildAnthropicAuthConfig(apiKey: string, baseUrl?: string, options?: AnthropicAuthOptions): AnthropicAuthConfig;
30
+ /** Configured headers and fingerprint override policy for Anthropic hosted web search. */
31
+ export interface AnthropicSearchHeaderOptions {
32
+ /** The selected model's configured headers (`ModelRegistry.resolveModelHeaders`). */
33
+ modelHeaders?: Record<string, string>;
34
+ /** The selected model's `compat.allowAnthropicHeaderOverrides`. */
35
+ allowAnthropicHeaderOverrides?: boolean;
36
+ }
24
37
  /**
25
38
  * Builds HTTP headers for Anthropic API requests (search variant).
26
39
  *
27
- * Forwards `ANTHROPIC_CUSTOM_HEADERS` when the resolver deems them applicable
28
- * (Foundry mode, or a non-Anthropic base URL — typically an enterprise
29
- * gateway), matching the streaming path so web search behaves identically.
40
+ * Model headers and `ANTHROPIC_CUSTOM_HEADERS` (when the resolver deems them
41
+ * applicable: Foundry mode, or a non-Anthropic base URL such as an enterprise
42
+ * gateway) go through the same case-insensitive merge and enforced-header
43
+ * filtering as the streaming path, so web search behaves identically.
30
44
  */
31
- export declare function buildAnthropicSearchHeaders(auth: AnthropicAuthConfig): Record<string, string>;
45
+ export declare function buildAnthropicSearchHeaders(auth: AnthropicAuthConfig, options?: AnthropicSearchHeaderOptions): Record<string, string>;
32
46
  /**
33
47
  * Builds the full API URL for Anthropic messages endpoint.
34
48
  */
@@ -4,7 +4,7 @@ import { AssistantMessageEventStream } from "./event-stream.js";
4
4
  export interface GlyphCodec {
5
5
  /** Context with model-bound glyphs encoded and one conditional convention notice. */
6
6
  context: Context;
7
- /** Decodes glyph tokens in the stream's terminal assistant message. */
7
+ /** Decodes glyph tokens live: text and tool-call deltas, partial snapshots, completed tool calls, and the terminal message. */
8
8
  wrap(inner: AssistantMessageEventStream): AssistantMessageEventStream;
9
9
  /** Decodes Cursor Pi calls and encodes their provider-bound results. */
10
10
  wrapCursorExecHandlers(handlers: CursorExecHandlers): CursorExecHandlers;
@@ -10,7 +10,11 @@ export interface NormalizeSchemaOptions {
10
10
  unsupportedFields: (key: string) => boolean;
11
11
  normalizeFieldNames: boolean;
12
12
  collapseNullFields: boolean;
13
- normalizeTypeArrayToNullable: boolean;
13
+ /**
14
+ * Target takes a scalar `type` only: `["T", "null"]` becomes `T` (+ `nullable`)
15
+ * and a multi-type array becomes one `anyOf` branch per type.
16
+ */
17
+ scalarizeTypeArrays: boolean;
14
18
  stripNullableKeyword: boolean;
15
19
  autoPropertyOrdering: boolean;
16
20
  ensureObjectProperties: boolean;
@@ -66,8 +70,9 @@ export declare function normalizeSchemaForMCP(value: unknown): unknown;
66
70
  * rejected; collapse to `enum` with an inferred scalar `type`.
67
71
  * - `oneOf` is not an MFJS combinator (only `anyOf` is); residual `oneOf` is
68
72
  * folded into `anyOf`.
69
- * - `type` must be a scalar string; `type: [...]` arrays are reduced to a
70
- * single scalar (the `null` branch is dropped — `nullable` is unsupported).
73
+ * - `type` must be a scalar string; `["T", "null"]` reduces to `T` (the
74
+ * `null` member is dropped — `nullable` is unsupported) and a multi-type
75
+ * array becomes one typed `anyOf` branch per type.
71
76
  * - Enum-bearing nodes get an inferred `type` (the idiomatic MFJS form; a bare
72
77
  * `enum` is valid too) so `anyOf` branches always carry a `type`.
73
78
  * - Validation/decorative keywords (`minItems`, `maxItems`, `maxLength`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.8.4",
3
+ "version": "18.8.6",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
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.8.4",
159
- "@oh-my-pi/pi-catalog": "18.8.4",
160
- "@oh-my-pi/pi-natives": "18.8.4",
161
- "@oh-my-pi/pi-utils": "18.8.4",
162
- "@oh-my-pi/pi-wire": "18.8.4"
158
+ "@oh-my-pi/omptype": "18.8.6",
159
+ "@oh-my-pi/pi-catalog": "18.8.6",
160
+ "@oh-my-pi/pi-natives": "18.8.6",
161
+ "@oh-my-pi/pi-utils": "18.8.6",
162
+ "@oh-my-pi/pi-wire": "18.8.6"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -360,19 +360,25 @@ export class SessionAffinity implements SessionsApi {
360
360
  }
361
361
 
362
362
  /**
363
- * Copy every stored credential affinity from one live session to another.
363
+ * Copy stored credential affinities from one live session to another.
364
364
  *
365
365
  * The target receives its own sticky entries, so request resolution, usage
366
366
  * blocking, credential rotation, metadata, and persisted pins all continue
367
367
  * through the target session id without retaining a live dependency on the
368
- * source session.
368
+ * source session. `include` limits the copy to the providers it accepts,
369
+ * given whether the source's affinity is an explicit user pin.
369
370
  */
370
- inherit(sourceSessionId: string, targetSessionId: string): number {
371
+ inherit(
372
+ sourceSessionId: string,
373
+ targetSessionId: string,
374
+ include?: (provider: string, explicit: boolean) => boolean,
375
+ ): number {
371
376
  if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) return 0;
372
377
  let inherited = 0;
373
378
  for (const provider of this.#pool.providers()) {
374
379
  const credential = this.get(provider, sourceSessionId);
375
380
  if (!credential || !this.#permits(provider, targetSessionId, credential.index)) continue;
381
+ if (include && !include(provider, credential.explicit === true)) continue;
376
382
  this.record(
377
383
  provider,
378
384
  targetSessionId,
@@ -1,5 +1,5 @@
1
1
  import type { Provider } from "../types";
2
- import { resolveUsedFraction } from "../usage";
2
+ import { isWithinUsageReserve, resolveUsedFraction } from "../usage";
3
3
  import type {
4
4
  CredentialRankingContext,
5
5
  UsageCredential,
@@ -224,7 +224,7 @@ export class CredentialHealth implements HealthApi {
224
224
  return {
225
225
  credentialId: entry.id,
226
226
  credentialType,
227
- state: remainingFraction <= resolveReserveFraction(entry) ? "reserve" : "healthy",
227
+ state: isWithinUsageReserve(remainingFraction, resolveReserveFraction(entry)) ? "reserve" : "healthy",
228
228
  remainingFraction,
229
229
  };
230
230
  }),
package/src/auth/oauth.ts CHANGED
@@ -179,9 +179,20 @@ export class OAuthAccounts implements OAuthApi {
179
179
  options: AuthApiKeyOptions | undefined,
180
180
  ): Promise<OAuthAccessResolution> {
181
181
  try {
182
+ // tryOAuth refreshes only expired tokens; it resyncs this row from the store after a forced re-mint.
183
+ let index = selection.index;
184
+ if (options?.forceRefresh) {
185
+ await this.refresh(selection.credentialId, options.signal, {
186
+ reuseRecentMint: options.refreshReason === "auth-recovery",
187
+ reason: options.refreshReason,
188
+ });
189
+ // The refresh re-lists rows, so a concurrently removed row can shift this one's position.
190
+ index = this.#deps.pool.entries(provider).findIndex(entry => entry.id === selection.credentialId);
191
+ if (index === -1) throw new Error(`OAuth credential ${selection.credentialId} was removed during refresh`);
192
+ }
182
193
  const resolved = await this.#deps.selector.tryOAuth(
183
194
  provider,
184
- { credential: selection.credential, index: selection.index },
195
+ { credential: selection.credential, index },
185
196
  providerKey,
186
197
  undefined,
187
198
  options,
@@ -289,9 +300,10 @@ export class OAuthAccounts implements OAuthApi {
289
300
  * Resolve one stored OAuth credential by its durable storage row id.
290
301
  *
291
302
  * Unlike the normal session resolver, this method never ranks, rotates, or
292
- * falls back to sibling credentials. A forced refresh re-mints only the
293
- * requested row, preserving exact-account affinity for operations whose
294
- * provenance and policy boundary are tied to one workspace.
303
+ * falls back to sibling credentials. A generic forced refresh re-mints only the
304
+ * requested row; with `refreshReason: "auth-recovery"` it may instead reuse a
305
+ * still-usable recent mint of that row. Either way exact-account affinity holds
306
+ * for operations whose provenance and policy boundary are tied to one workspace.
295
307
  *
296
308
  * Returns `undefined` when the row does not exist for `provider` or an
297
309
  * explicit runtime/config API-key override suppresses OAuth.
@@ -93,6 +93,7 @@ export interface OAuthRefresherDeps {
93
93
  pool: CredentialPool;
94
94
  policies: AccountPolicies;
95
95
  override?: AuthStorageOptions["refreshOAuthCredential"];
96
+ overrideMints?: AuthStorageOptions["refreshOAuthCredentialMints"];
96
97
  }
97
98
 
98
99
  /** Single-flighted, lease-guarded OAuth refresh with compare-and-set persistence. */
@@ -139,6 +140,13 @@ export class OAuthRefresher {
139
140
  this.#recentMints.set(id, { provider, access: credential.access, at: now });
140
141
  }
141
142
 
143
+ /** Whether refreshes run the provider token exchange in this process rather than delegating it. */
144
+ #mintsLocally(): boolean {
145
+ return this.#deps.override
146
+ ? this.#deps.overrideMints === true
147
+ : this.#deps.store.refreshOAuthCredential === undefined;
148
+ }
149
+
142
150
  /**
143
151
  * Refresh one stored OAuth credential under durable row ownership.
144
152
  */
@@ -345,7 +353,7 @@ export class OAuthRefresher {
345
353
  } else {
346
354
  this.#deps.store.updateAuthCredential(row.id, merged);
347
355
  }
348
- if (this.#deps.override === undefined && this.#deps.store.refreshOAuthCredential === undefined) {
356
+ if (this.#mintsLocally()) {
349
357
  this.#rememberMint(provider, row.id, merged);
350
358
  }
351
359
  this.#deps.pool.replace(
@@ -462,11 +470,7 @@ export class OAuthRefresher {
462
470
  .then(refreshed => {
463
471
  // A delegated refresh may have returned a broker-cached token rather
464
472
  // than minting one. Only direct local requests can establish mint time.
465
- if (
466
- !hasRefreshLeases(this.#deps.store) &&
467
- this.#deps.override === undefined &&
468
- this.#deps.store.refreshOAuthCredential === undefined
469
- ) {
473
+ if (!hasRefreshLeases(this.#deps.store) && this.#mintsLocally()) {
470
474
  this.#rememberMint(provider, credentialId, refreshed);
471
475
  }
472
476
  return refreshed;
@@ -613,7 +617,7 @@ export class OAuthRefresher {
613
617
  const promise = (async () => {
614
618
  this.#deps.pool.bump("credential-refresh-start");
615
619
  try {
616
- return await this.#forceRefreshCredentialByIdUnshared(id, signal);
620
+ return await this.#forceRefreshCredentialByIdUnshared(id, signal, options?.reason);
617
621
  } catch (error) {
618
622
  this.#deps.pool.bump("credential-refresh-failure");
619
623
  throw error;
@@ -625,7 +629,11 @@ export class OAuthRefresher {
625
629
  return raceSignal(promise, signal, "credential refresh aborted");
626
630
  }
627
631
 
628
- async #forceRefreshCredentialByIdUnshared(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
632
+ async #forceRefreshCredentialByIdUnshared(
633
+ id: number,
634
+ signal?: AbortSignal,
635
+ reason?: OAuthRefreshReason,
636
+ ): Promise<AuthCredentialSnapshotEntry> {
629
637
  for (const provider of this.#deps.pool.providers()) {
630
638
  const entries = this.#deps.pool.entries(provider);
631
639
  const index = entries.findIndex(entry => entry.id === id);
@@ -645,7 +653,7 @@ export class OAuthRefresher {
645
653
  const stale: OAuthCredential = { ...attempted, expires: 0 };
646
654
  let refreshed: OAuthCredentials;
647
655
  try {
648
- refreshed = await this.#refreshSingleFlight(provider as Provider, stale, id, signal);
656
+ refreshed = await this.#refreshSingleFlight(provider as Provider, stale, id, signal, reason);
649
657
  } catch (error) {
650
658
  // A definitively-dead grant tears the row down here, where the
651
659
  // attempted credential is known. CAS on the persisted credential so a
@@ -4,6 +4,7 @@ import { getOAuthApiKey, getOAuthProvider } from "../registry/oauth";
4
4
  import type { OAuthCredentials, OAuthProvider } from "../registry/oauth/types";
5
5
  import type { Provider } from "../types";
6
6
  import type { CredentialRankingContext, CredentialRankingStrategy, PlanGate, UsageReport } from "../usage";
7
+ import { isWithinUsageReserve } from "../usage";
7
8
  import type { RankingStrategyResolver } from "../usage/registry";
8
9
  import type { SessionAffinity } from "./affinity";
9
10
  import {
@@ -28,12 +29,13 @@ import {
28
29
  } from "./rank";
29
30
  import { mergeRefreshedCredential, OAUTH_REFRESH_SKEW_MS, type OAuthRefresher } from "./refresh";
30
31
  import type { AuthCredentialStore } from "./store";
31
- import type {
32
- ApiKeyCredential,
33
- AuthApiKeyOptions,
34
- AuthCredential,
35
- OAuthCredential,
36
- StoredAuthCredential,
32
+ import {
33
+ type ApiKeyCredential,
34
+ type AuthApiKeyOptions,
35
+ type AuthCredential,
36
+ type OAuthCredential,
37
+ oauthAccountKey,
38
+ type StoredAuthCredential,
37
39
  } from "./types";
38
40
  import type { UsageService } from "./usage";
39
41
  import {
@@ -475,7 +477,9 @@ export class CredentialSelector {
475
477
  blocked,
476
478
  blockedUntil,
477
479
  inReserve:
478
- reserveFraction !== undefined && remainingFraction !== undefined && remainingFraction <= reserveFraction,
480
+ reserveFraction !== undefined &&
481
+ remainingFraction !== undefined &&
482
+ isWithinUsageReserve(remainingFraction, reserveFraction),
479
483
  reserveMeasured: reserveFraction !== undefined && remainingFraction !== undefined,
480
484
  accountPriority: policy?.priority === undefined || !Number.isFinite(policy.priority) ? 0 : policy.priority,
481
485
  allowanceSpent: remainingFraction === 0,
@@ -547,9 +551,10 @@ export class CredentialSelector {
547
551
  const accountIds = options?.accountIds?.length ? new Set(options.accountIds) : undefined;
548
552
  const enforceAccounts =
549
553
  accountIds !== undefined &&
550
- credentials.some(
551
- ({ credential }) => credential.accountId !== undefined && accountIds.has(credential.accountId),
552
- );
554
+ credentials.some(({ credential }) => {
555
+ const accountKey = oauthAccountKey(credential);
556
+ return accountKey !== undefined && accountIds.has(accountKey);
557
+ });
553
558
  const hasAccountPolicy = credentials.some(
554
559
  ({ credential }) => this.#deps.policies.forCredential(provider, credential) !== undefined,
555
560
  );
@@ -883,8 +888,8 @@ export class CredentialSelector {
883
888
  for (const pass of passes) {
884
889
  for (const candidate of candidates) {
885
890
  if (preflightFailures.has(candidate)) continue;
886
- const candidateAccountId = candidate.selection.credential.accountId;
887
- if (pass.enforceAccounts && (candidateAccountId === undefined || !accountIds?.has(candidateAccountId)))
891
+ const candidateAccountKey = oauthAccountKey(candidate.selection.credential);
892
+ if (pass.enforceAccounts && (candidateAccountKey === undefined || !accountIds?.has(candidateAccountKey)))
888
893
  continue;
889
894
  const resolved = await this.tryOAuth(provider, candidate.selection, providerKey, sessionId, options, {
890
895
  checkUsage,