@oh-my-pi/pi-ai 18.8.4 → 18.8.5
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 +19 -0
- package/dist/types/auth/affinity.d.ts +4 -3
- package/dist/types/auth/oauth.d.ts +4 -3
- package/dist/types/auth/refresh.d.ts +1 -0
- package/dist/types/auth/select.d.ts +1 -1
- package/dist/types/auth/types.d.ts +38 -10
- package/dist/types/auth-broker/remote-store.d.ts +4 -3
- package/dist/types/providers/anthropic.d.ts +6 -0
- package/dist/types/providers/cursor.d.ts +7 -7
- package/dist/types/usage.d.ts +2 -0
- package/dist/types/utils/anthropic-auth.d.ts +21 -7
- package/package.json +6 -6
- package/src/auth/affinity.ts +9 -3
- package/src/auth/health.ts +2 -2
- package/src/auth/oauth.ts +16 -4
- package/src/auth/refresh.ts +17 -9
- package/src/auth/select.ts +17 -12
- package/src/auth/types.ts +46 -11
- package/src/auth-broker/remote-store.ts +37 -7
- package/src/auth-storage.ts +7 -1
- package/src/providers/anthropic.ts +6 -4
- package/src/providers/cursor.ts +8 -8
- package/src/usage.ts +8 -0
- package/src/utils/anthropic-auth.ts +34 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.8.5] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `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))
|
|
10
|
+
- `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))
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `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)).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- 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))
|
|
19
|
+
- 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))
|
|
20
|
+
- 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))
|
|
21
|
+
- 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))
|
|
22
|
+
- 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))
|
|
23
|
+
|
|
5
24
|
## [18.8.4] - 2026-10-08
|
|
6
25
|
|
|
7
26
|
### Breaking Changes
|
|
@@ -79,14 +79,15 @@ export declare class SessionAffinity implements SessionsApi {
|
|
|
79
79
|
restoredAtMs?: number;
|
|
80
80
|
}): boolean;
|
|
81
81
|
/**
|
|
82
|
-
* Copy
|
|
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
|
|
72
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
971
|
-
*
|
|
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
|
|
1006
|
-
*
|
|
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
|
|
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
|
|
153
|
-
*
|
|
154
|
-
* (pre-endpoint broker) disables
|
|
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
|
|
173
|
-
* stamped {@link kCursorExecResolved} the moment they open,
|
|
174
|
-
* synthesizes no placeholder for them and only their
|
|
175
|
-
* pairs a result. A transport that closes before
|
|
176
|
-
* call unpaired, and `buildSessionContext` strips
|
|
177
|
-
* rebuilt transcript — the interaction disappears.
|
|
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
|
package/dist/types/usage.d.ts
CHANGED
|
@@ -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`
|
|
21
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* gateway)
|
|
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
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.8.
|
|
3
|
+
"version": "18.8.5",
|
|
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.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.8.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.8.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.8.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.8.
|
|
158
|
+
"@oh-my-pi/omptype": "18.8.5",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.8.5",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.8.5",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.8.5",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.8.5"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
package/src/auth/affinity.ts
CHANGED
|
@@ -360,19 +360,25 @@ export class SessionAffinity implements SessionsApi {
|
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
/**
|
|
363
|
-
* Copy
|
|
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(
|
|
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,
|
package/src/auth/health.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
294
|
-
*
|
|
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.
|
package/src/auth/refresh.ts
CHANGED
|
@@ -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.#
|
|
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(
|
|
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
|
package/src/auth/select.ts
CHANGED
|
@@ -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
|
|
32
|
-
ApiKeyCredential,
|
|
33
|
-
AuthApiKeyOptions,
|
|
34
|
-
AuthCredential,
|
|
35
|
-
OAuthCredential,
|
|
36
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
887
|
-
if (pass.enforceAccounts && (
|
|
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,
|
package/src/auth/types.ts
CHANGED
|
@@ -373,6 +373,12 @@ export type AuthStorageOptions = {
|
|
|
373
373
|
signal?: AbortSignal,
|
|
374
374
|
reason?: OAuthRefreshReason,
|
|
375
375
|
) => Promise<OAuthCredentials>;
|
|
376
|
+
/**
|
|
377
|
+
* Set when {@link refreshOAuthCredential} exchanges the refresh token with the
|
|
378
|
+
* provider itself instead of delegating (e.g. to a broker), so its tokens count
|
|
379
|
+
* as this process's mints for auth-recovery reuse.
|
|
380
|
+
*/
|
|
381
|
+
refreshOAuthCredentialMints?: boolean;
|
|
376
382
|
/**
|
|
377
383
|
* Human-readable description of the credential store backing this
|
|
378
384
|
* AuthStorage instance. Surfaced through {@link AuthStorage.keys.describe}
|
|
@@ -472,7 +478,7 @@ export interface ModelUsageHealthOptions {
|
|
|
472
478
|
export type AuthApiKeyOptions = {
|
|
473
479
|
baseUrl?: string;
|
|
474
480
|
modelId?: string;
|
|
475
|
-
/**
|
|
481
|
+
/** {@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. */
|
|
476
482
|
accountIds?: readonly string[];
|
|
477
483
|
/**
|
|
478
484
|
* Caller's cancel signal. Threaded into any broker-bound OAuth refresh so
|
|
@@ -493,6 +499,22 @@ export type AuthApiKeyOptions = {
|
|
|
493
499
|
recordAffinity?: boolean;
|
|
494
500
|
};
|
|
495
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Identity that multi-account discovery records in `Model.accountAccess` and
|
|
504
|
+
* that {@link AuthApiKeyOptions.accountIds} selection matches: the provider
|
|
505
|
+
* account id (Codex `chatgpt_account_id`), else the login email for providers
|
|
506
|
+
* whose credentials carry no account id (Antigravity), else the project id for
|
|
507
|
+
* a login whose email lookup failed. Same precedence as the credential store's
|
|
508
|
+
* identity key.
|
|
509
|
+
*/
|
|
510
|
+
export function oauthAccountKey(identity: {
|
|
511
|
+
accountId?: string;
|
|
512
|
+
email?: string;
|
|
513
|
+
projectId?: string;
|
|
514
|
+
}): string | undefined {
|
|
515
|
+
return identity.accountId ?? identity.email ?? identity.projectId;
|
|
516
|
+
}
|
|
517
|
+
|
|
496
518
|
/** Non-secret identity bound to the OAuth credential selected for one request attempt. */
|
|
497
519
|
export interface OAuthRequestIdentity {
|
|
498
520
|
orgId?: string;
|
|
@@ -998,7 +1020,7 @@ export interface KeysApi {
|
|
|
998
1020
|
resolver(provider: string, options?: { sessionId?: string; baseUrl?: string; modelId?: string }): ApiKeyResolver;
|
|
999
1021
|
}
|
|
1000
1022
|
|
|
1001
|
-
/** Controls whether a row-id refresh may reuse a
|
|
1023
|
+
/** Controls whether a row-id refresh may reuse a recently minted token. */
|
|
1002
1024
|
export interface OAuthRefreshByIdOptions {
|
|
1003
1025
|
/**
|
|
1004
1026
|
* Return the stored credential when it still holds a fresh access token this
|
|
@@ -1006,6 +1028,12 @@ export interface OAuthRefreshByIdOptions {
|
|
|
1006
1028
|
* refresh tokens repeatedly when a provider rejects every valid bearer.
|
|
1007
1029
|
*/
|
|
1008
1030
|
reuseRecentMint?: boolean;
|
|
1031
|
+
/**
|
|
1032
|
+
* Provider-401 recovery intent. Forwarded to a delegated refresh (an auth
|
|
1033
|
+
* broker), which may then return a token it minted in the last five minutes.
|
|
1034
|
+
* Local reuse still requires {@link reuseRecentMint}.
|
|
1035
|
+
*/
|
|
1036
|
+
reason?: OAuthRefreshReason;
|
|
1009
1037
|
}
|
|
1010
1038
|
|
|
1011
1039
|
/** OAuth login, access, account identity, and refresh operations. */
|
|
@@ -1048,9 +1076,10 @@ export interface OAuthApi {
|
|
|
1048
1076
|
* Resolve one stored OAuth credential by its durable storage row id.
|
|
1049
1077
|
*
|
|
1050
1078
|
* Unlike the normal session resolver, this method never ranks, rotates, or
|
|
1051
|
-
* falls back to sibling credentials. A forced refresh re-mints only the
|
|
1052
|
-
* requested row
|
|
1053
|
-
*
|
|
1079
|
+
* falls back to sibling credentials. A generic forced refresh re-mints only the
|
|
1080
|
+
* requested row; with `refreshReason: "auth-recovery"` it may instead reuse a
|
|
1081
|
+
* still-usable recent mint of that row. Either way exact-account affinity holds
|
|
1082
|
+
* for operations whose provenance and policy boundary are tied to one workspace.
|
|
1054
1083
|
*
|
|
1055
1084
|
* Returns `undefined` when the row does not exist for `provider` or an
|
|
1056
1085
|
* explicit runtime/config API-key override suppresses OAuth.
|
|
@@ -1088,8 +1117,9 @@ export interface OAuthApi {
|
|
|
1088
1117
|
* Refresh the OAuth credential with the given id through a per-credential
|
|
1089
1118
|
* single-flight. Concurrent callers for the same row await the same upstream
|
|
1090
1119
|
* refresh attempt, which is required for providers that rotate refresh tokens
|
|
1091
|
-
* on every successful refresh. Mints
|
|
1092
|
-
*
|
|
1120
|
+
* on every successful refresh. Mints unless {@link OAuthRefreshByIdOptions.reuseRecentMint}
|
|
1121
|
+
* finds a recent local mint, or a delegated refresh given
|
|
1122
|
+
* {@link OAuthRefreshByIdOptions.reason} `"auth-recovery"` returns its recent mint.
|
|
1093
1123
|
*/
|
|
1094
1124
|
refresh(id: number, signal?: AbortSignal, options?: OAuthRefreshByIdOptions): Promise<AuthCredentialSnapshotEntry>;
|
|
1095
1125
|
/**
|
|
@@ -1124,14 +1154,19 @@ export interface SessionsApi {
|
|
|
1124
1154
|
*/
|
|
1125
1155
|
pin(provider: string, sessionId: string, credentialId: number, options?: { restoredAtMs?: number }): boolean;
|
|
1126
1156
|
/**
|
|
1127
|
-
* Copy
|
|
1157
|
+
* Copy stored credential affinities from one live session to another.
|
|
1128
1158
|
*
|
|
1129
1159
|
* The target receives its own sticky entries, so request resolution, usage
|
|
1130
1160
|
* blocking, credential rotation, metadata, and persisted pins all continue
|
|
1131
1161
|
* through the target session id without retaining a live dependency on the
|
|
1132
|
-
* source session.
|
|
1133
|
-
|
|
1134
|
-
|
|
1162
|
+
* source session. `include` limits the copy to the providers it accepts,
|
|
1163
|
+
* given whether the source's affinity is an explicit user pin.
|
|
1164
|
+
*/
|
|
1165
|
+
inherit(
|
|
1166
|
+
sourceSessionId: string,
|
|
1167
|
+
targetSessionId: string,
|
|
1168
|
+
include?: (provider: string, explicit: boolean) => boolean,
|
|
1169
|
+
): number;
|
|
1135
1170
|
/**
|
|
1136
1171
|
* Restrict one session's credentials for `provider` to the OAuth accounts
|
|
1137
1172
|
* whose identity key (`email:<address>|org:<id>` for org-scoped providers;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* runs isn't required.
|
|
9
9
|
*/
|
|
10
10
|
import * as os from "node:os";
|
|
11
|
-
import { getAppName, getInstallId, logger } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import { getAppName, getInstallId, logger, postmortem } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import type { AuthCredentialStore } from "../auth/store";
|
|
13
13
|
import {
|
|
14
14
|
type AuthCredential,
|
|
@@ -69,6 +69,8 @@ const BACKGROUND_BACKOFF_INITIAL_MS = 500;
|
|
|
69
69
|
const BACKGROUND_BACKOFF_MAX_MS = 30_000;
|
|
70
70
|
/** Idle window after the last foreground store use before background sync parks. */
|
|
71
71
|
const BACKGROUND_IDLE_MS = 20_000;
|
|
72
|
+
/** Longest a process exit waits for the final observed-usage report to reach the broker. */
|
|
73
|
+
const OBSERVED_USAGE_EXIT_FLUSH_MS = 2_000;
|
|
72
74
|
|
|
73
75
|
function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBlockSnapshot {
|
|
74
76
|
return {
|
|
@@ -318,6 +320,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
318
320
|
readonly #observedUsageFlushMs: number;
|
|
319
321
|
/** Latched once the broker answered 404 — old broker, never report again. */
|
|
320
322
|
#observedUsageUnsupported = false;
|
|
323
|
+
/** Tail of the serialized observed-usage sends; each flush waits for the one before it. */
|
|
324
|
+
#observedUsageFlush: Promise<void> = Promise.resolve();
|
|
325
|
+
/** Cancels the exit flush registered with the first buffered usage. */
|
|
326
|
+
#cancelObservedUsageExitFlush: (() => void) | undefined;
|
|
321
327
|
|
|
322
328
|
constructor(opts: RemoteAuthCredentialStoreOptions) {
|
|
323
329
|
this.#client = opts.client;
|
|
@@ -1456,9 +1462,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1456
1462
|
|
|
1457
1463
|
/**
|
|
1458
1464
|
* Fold locally observed request usage into the pending report and schedule
|
|
1459
|
-
* a flush. One `POST /v1/usage/observed` at most per flush interval
|
|
1460
|
-
*
|
|
1461
|
-
* (pre-endpoint broker) disables
|
|
1465
|
+
* a flush. One `POST /v1/usage/observed` at most per flush interval, plus a
|
|
1466
|
+
* final one when the process exits; on failure the batch is retained and
|
|
1467
|
+
* retried with the next flush. A 404 (pre-endpoint broker) disables
|
|
1468
|
+
* reporting for the life of this store.
|
|
1462
1469
|
*
|
|
1463
1470
|
* `client` overrides the reporting identity — the auth-gateway attributes
|
|
1464
1471
|
* each request to the originating install/app instead of the gateway host.
|
|
@@ -1488,9 +1495,31 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1488
1495
|
}, this.#observedUsageFlushMs);
|
|
1489
1496
|
this.#observedUsageTimer.unref?.();
|
|
1490
1497
|
}
|
|
1498
|
+
// The timer never holds the process open, so a process that exits first (a
|
|
1499
|
+
// one-shot run, a signal) sends what is still buffered on the way out. The
|
|
1500
|
+
// wait is bounded: an unreachable broker must not stall the exit.
|
|
1501
|
+
this.#cancelObservedUsageExitFlush ??= postmortem.register("auth-broker-observed-usage", () =>
|
|
1502
|
+
raceSignal(
|
|
1503
|
+
this.#flushObservedUsage(),
|
|
1504
|
+
AbortSignal.timeout(OBSERVED_USAGE_EXIT_FLUSH_MS),
|
|
1505
|
+
"observed usage exit flush timed out",
|
|
1506
|
+
).catch(error => {
|
|
1507
|
+
logger.debug("auth-broker observed usage dropped at exit", { error: String(error) });
|
|
1508
|
+
}),
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
#flushObservedUsage(): Promise<void> {
|
|
1513
|
+
// The tail never rejects, so one failed send cannot stall every later flush.
|
|
1514
|
+
this.#observedUsageFlush = this.#observedUsageFlush
|
|
1515
|
+
.then(() => this.#sendObservedUsage())
|
|
1516
|
+
.catch(error => {
|
|
1517
|
+
logger.debug("auth-broker observed usage flush failed", { error: String(error) });
|
|
1518
|
+
});
|
|
1519
|
+
return this.#observedUsageFlush;
|
|
1491
1520
|
}
|
|
1492
1521
|
|
|
1493
|
-
async #
|
|
1522
|
+
async #sendObservedUsage(): Promise<void> {
|
|
1494
1523
|
if (this.#observedUsage.size === 0 || this.#observedUsageUnsupported) return;
|
|
1495
1524
|
const batch = [...this.#observedUsage.values()];
|
|
1496
1525
|
this.#observedUsage.clear();
|
|
@@ -1539,8 +1568,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1539
1568
|
clearTimeout(this.#observedUsageTimer);
|
|
1540
1569
|
this.#observedUsageTimer = undefined;
|
|
1541
1570
|
}
|
|
1542
|
-
//
|
|
1543
|
-
|
|
1571
|
+
// Final flush. The exit registration outlives close() until it settles, so a
|
|
1572
|
+
// process exiting right after close() still sends it; failures are dropped.
|
|
1573
|
+
void this.#flushObservedUsage().finally(() => this.#cancelObservedUsageExitFlush?.());
|
|
1544
1574
|
this.#cache.clear();
|
|
1545
1575
|
this.#usageOverlays.clear();
|
|
1546
1576
|
}
|
package/src/auth-storage.ts
CHANGED
|
@@ -185,7 +185,13 @@ export class AuthStorage {
|
|
|
185
185
|
affinity.clearProvider(provider);
|
|
186
186
|
},
|
|
187
187
|
});
|
|
188
|
-
const refresher = new OAuthRefresher({
|
|
188
|
+
const refresher = new OAuthRefresher({
|
|
189
|
+
store,
|
|
190
|
+
pool,
|
|
191
|
+
policies,
|
|
192
|
+
override: options.refreshOAuthCredential,
|
|
193
|
+
overrideMints: options.refreshOAuthCredentialMints,
|
|
194
|
+
});
|
|
189
195
|
const usageProviders = options.usageProviderResolver ?? defaultUsageProvider;
|
|
190
196
|
// Key reports by the effective provider (runtime extension override first), so an
|
|
191
197
|
// override's `cacheVersion` separates its rows from other processes sharing the store.
|
|
@@ -1307,10 +1307,12 @@ function buildClaudeCodeTlsFetchOptions(
|
|
|
1307
1307
|
},
|
|
1308
1308
|
};
|
|
1309
1309
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1310
|
+
/**
|
|
1311
|
+
* Merge headers case-insensitively; later sources win and preserve their key casing.
|
|
1312
|
+
* Undefined sources are ignored. Differently cased duplicates are removed so the
|
|
1313
|
+
* Headers constructor cannot join competing values comma-separated on the wire.
|
|
1314
|
+
*/
|
|
1315
|
+
export function mergeHeaders(...headerSources: (Record<string, string> | undefined)[]): Record<string, string> {
|
|
1314
1316
|
const merged: Record<string, string> = {};
|
|
1315
1317
|
const keyByLower = new Map<string, string>();
|
|
1316
1318
|
for (const headers of headerSources) {
|
package/src/providers/cursor.ts
CHANGED
|
@@ -4496,13 +4496,13 @@ function isExecOwnedToolCall(toolCall: { tool?: { case?: string } } | undefined)
|
|
|
4496
4496
|
* so reparsing unconditionally would erase the arguments of every such block
|
|
4497
4497
|
* caught open by a truncated stream.
|
|
4498
4498
|
*
|
|
4499
|
-
* Server-owned blocks are also paired here. `connect-scm
|
|
4500
|
-
* stamped {@link kCursorExecResolved} the moment they open,
|
|
4501
|
-
* synthesizes no placeholder for them and only their
|
|
4502
|
-
* pairs a result. A transport that closes before
|
|
4503
|
-
* call unpaired, and `buildSessionContext` strips
|
|
4504
|
-
* rebuilt transcript — the interaction disappears.
|
|
4505
|
-
* emitted instead.
|
|
4499
|
+
* Server-owned blocks are also paired here. `connect-scm`, `todo` and
|
|
4500
|
+
* `web-fetch` are stamped {@link kCursorExecResolved} the moment they open,
|
|
4501
|
+
* so `agent-loop.ts` synthesizes no placeholder for them and only their
|
|
4502
|
+
* `toolCallCompleted` frame pairs a result. A transport that closes before
|
|
4503
|
+
* that frame would leave the call unpaired, and `buildSessionContext` strips
|
|
4504
|
+
* a dangling call from every rebuilt transcript — the interaction disappears.
|
|
4505
|
+
* An interrupted result is emitted instead.
|
|
4506
4506
|
*
|
|
4507
4507
|
* MCP blocks are excluded even when resolved: the exec dispatch that marked
|
|
4508
4508
|
* them owns their result, and `drainInFlightDispatches` awaits it before this
|
|
@@ -4523,7 +4523,7 @@ export function flushOpenToolCalls(
|
|
|
4523
4523
|
clearStreamingPartialJson(block);
|
|
4524
4524
|
}
|
|
4525
4525
|
const kind = block[kStreamingBlockKind];
|
|
4526
|
-
if (kind === "connect-scm" || kind === "todo" || kind === "cursor-edit") {
|
|
4526
|
+
if (kind === "connect-scm" || kind === "todo" || kind === "cursor-edit" || kind === "web-fetch") {
|
|
4527
4527
|
if (!(kind === "cursor-edit" && state.pairedEditToolCallIds?.has(block.id))) {
|
|
4528
4528
|
state.onToolResult?.({
|
|
4529
4529
|
role: "toolResult",
|
package/src/usage.ts
CHANGED
|
@@ -169,6 +169,14 @@ export function resolveUsedFraction(limit: UsageLimit): number | undefined {
|
|
|
169
169
|
return undefined;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/** Float slack so a remaining fraction derived as `1 - used` still matches a reserve it sits exactly on. */
|
|
173
|
+
const RESERVE_BOUNDARY_EPSILON = 1e-9;
|
|
174
|
+
|
|
175
|
+
/** Whether remaining quota is at or below the reserve (both 0..1 fractions). */
|
|
176
|
+
export function isWithinUsageReserve(remainingFraction: number, reserveFraction: number): boolean {
|
|
177
|
+
return remainingFraction <= reserveFraction + RESERVE_BOUNDARY_EPSILON;
|
|
178
|
+
}
|
|
179
|
+
|
|
172
180
|
/**
|
|
173
181
|
* One recorded usage-limit snapshot: a single limit window of one account at
|
|
174
182
|
* a point in time. The usage cache itself is latest-snapshot-only; history
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* through {@link buildAnthropicAuthConfig} for header/URL shaping.
|
|
10
10
|
*/
|
|
11
11
|
import { $env } from "@oh-my-pi/pi-utils";
|
|
12
|
-
import { buildAnthropicHeaders, resolveAnthropicCustomHeadersForBaseUrl } from "../providers/anthropic";
|
|
12
|
+
import { buildAnthropicHeaders, mergeHeaders, resolveAnthropicCustomHeadersForBaseUrl } from "../providers/anthropic";
|
|
13
13
|
import { normalizeAnthropicBaseUrl } from "../providers/anthropic-state";
|
|
14
14
|
import { isFoundryEnabled } from "./foundry";
|
|
15
15
|
|
|
@@ -43,6 +43,12 @@ export function isOAuthToken(apiKey: string): boolean {
|
|
|
43
43
|
return apiKey.includes("sk-ant-oat");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Optional request-shaping overrides for an already-resolved Anthropic credential. */
|
|
47
|
+
export interface AnthropicAuthOptions {
|
|
48
|
+
/** Override token detection; false explicitly disables OAuth-style request shaping. */
|
|
49
|
+
isOAuth?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
/**
|
|
47
53
|
* Build an {@link AnthropicAuthConfig} from an already-resolved API key.
|
|
48
54
|
*
|
|
@@ -51,32 +57,50 @@ export function isOAuthToken(apiKey: string): boolean {
|
|
|
51
57
|
* env-derived base; pass `undefined` to fall back to FOUNDRY/ANTHROPIC env
|
|
52
58
|
* resolution and finally `DEFAULT_BASE_URL`.
|
|
53
59
|
*
|
|
54
|
-
* `isOAuth`
|
|
55
|
-
*
|
|
60
|
+
* `options.isOAuth` defaults to token detection. Pass the selected model's
|
|
61
|
+
* `isOAuth` to match the streaming path, which lets the model override the
|
|
62
|
+
* prefix (custom `anthropic-messages` providers default to OAuth shaping).
|
|
56
63
|
*/
|
|
57
|
-
export function buildAnthropicAuthConfig(
|
|
64
|
+
export function buildAnthropicAuthConfig(
|
|
65
|
+
apiKey: string,
|
|
66
|
+
baseUrl?: string,
|
|
67
|
+
options: AnthropicAuthOptions = {},
|
|
68
|
+
): AnthropicAuthConfig {
|
|
58
69
|
return {
|
|
59
70
|
apiKey,
|
|
60
71
|
baseUrl: normalizeBaseUrl(baseUrl) ?? resolveAnthropicBaseUrlFromEnv() ?? DEFAULT_BASE_URL,
|
|
61
|
-
isOAuth: isOAuthToken(apiKey),
|
|
72
|
+
isOAuth: options.isOAuth ?? isOAuthToken(apiKey),
|
|
62
73
|
};
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
/** Configured headers and fingerprint override policy for Anthropic hosted web search. */
|
|
77
|
+
export interface AnthropicSearchHeaderOptions {
|
|
78
|
+
/** The selected model's configured headers (`ModelRegistry.resolveModelHeaders`). */
|
|
79
|
+
modelHeaders?: Record<string, string>;
|
|
80
|
+
/** The selected model's `compat.allowAnthropicHeaderOverrides`. */
|
|
81
|
+
allowAnthropicHeaderOverrides?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* Builds HTTP headers for Anthropic API requests (search variant).
|
|
67
86
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* gateway)
|
|
87
|
+
* Model headers and `ANTHROPIC_CUSTOM_HEADERS` (when the resolver deems them
|
|
88
|
+
* applicable: Foundry mode, or a non-Anthropic base URL such as an enterprise
|
|
89
|
+
* gateway) go through the same case-insensitive merge and enforced-header
|
|
90
|
+
* filtering as the streaming path, so web search behaves identically.
|
|
71
91
|
*/
|
|
72
|
-
export function buildAnthropicSearchHeaders(
|
|
92
|
+
export function buildAnthropicSearchHeaders(
|
|
93
|
+
auth: AnthropicAuthConfig,
|
|
94
|
+
options: AnthropicSearchHeaderOptions = {},
|
|
95
|
+
): Record<string, string> {
|
|
73
96
|
return buildAnthropicHeaders({
|
|
74
97
|
apiKey: auth.apiKey,
|
|
75
98
|
baseUrl: auth.baseUrl,
|
|
76
99
|
isOAuth: auth.isOAuth,
|
|
77
100
|
extraBetas: ["web-search-2025-03-05"],
|
|
78
101
|
stream: false,
|
|
79
|
-
modelHeaders: resolveAnthropicCustomHeadersForBaseUrl(auth.baseUrl),
|
|
102
|
+
modelHeaders: mergeHeaders(options.modelHeaders, resolveAnthropicCustomHeadersForBaseUrl(auth.baseUrl)),
|
|
103
|
+
allowAnthropicHeaderOverrides: options.allowAnthropicHeaderOverrides,
|
|
80
104
|
});
|
|
81
105
|
}
|
|
82
106
|
|