@oh-my-pi/pi-ai 18.8.2 → 18.8.4

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 (36) hide show
  1. package/CHANGELOG.md +33 -64
  2. package/dist/types/auth/cascade.d.ts +12 -3
  3. package/dist/types/auth/oauth.d.ts +5 -3
  4. package/dist/types/auth/select.d.ts +2 -0
  5. package/dist/types/auth/store.d.ts +2 -2
  6. package/dist/types/auth/types.d.ts +17 -4
  7. package/dist/types/auth-broker/client.d.ts +1 -1
  8. package/dist/types/auth-broker/remote-store.d.ts +1 -1
  9. package/dist/types/auth-broker/server.d.ts +2 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +2 -0
  11. package/dist/types/auth-gateway/http.d.ts +0 -2
  12. package/dist/types/error/auth.d.ts +10 -0
  13. package/dist/types/provider-session-state.d.ts +2 -2
  14. package/dist/types/registry/oauth/cursor.d.ts +1 -1
  15. package/dist/types/utils/resolve-peer.d.ts +2 -0
  16. package/package.json +6 -6
  17. package/src/auth/cascade.ts +36 -6
  18. package/src/auth/oauth.ts +5 -3
  19. package/src/auth/resets.ts +1 -1
  20. package/src/auth/select.ts +30 -10
  21. package/src/auth/store.ts +2 -2
  22. package/src/auth/types.ts +17 -4
  23. package/src/auth/usage.ts +1 -1
  24. package/src/auth-broker/client.ts +5 -2
  25. package/src/auth-broker/remote-store.ts +2 -2
  26. package/src/auth-broker/server.ts +33 -7
  27. package/src/auth-gateway/dispatch.ts +2 -0
  28. package/src/auth-gateway/http.ts +0 -8
  29. package/src/auth-gateway/server.ts +10 -9
  30. package/src/error/auth.ts +20 -0
  31. package/src/provider-session-state.ts +2 -2
  32. package/src/providers/anthropic.ts +52 -8
  33. package/src/providers/cursor.ts +27 -1
  34. package/src/registry/oauth/cursor.ts +25 -16
  35. package/src/usage/github-copilot.ts +1 -1
  36. package/src/utils/resolve-peer.ts +7 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.4] - 2026-10-08
6
+
7
+ ### Breaking Changes
8
+
9
+ - `AuthBrokerClient.notifyUsageStale` and `UsageLedgerStore.invalidateUsageCache` (including `RemoteAuthCredentialStore.invalidateUsageCache`) now take an optional leading `provider` argument: the signature is `(provider?: string, signal?: AbortSignal)` instead of `(signal?: AbortSignal)` ([#14761](https://github.com/can1357/oh-my-pi/pull/14761) by [@will-bogusz](https://github.com/will-bogusz))
10
+
11
+ ### Added
12
+
13
+ - Auth gateway route option `excludeProviders` leaves those providers' accounts out of `/v1/usage` and `/v1/credentials/check` ([#14755](https://github.com/can1357/oh-my-pi/pull/14755) by [@will-bogusz](https://github.com/will-bogusz))
14
+
15
+ ### Fixed
16
+
17
+ - Fixed a single-provider usage refresh from an auth broker client (`omp usage invalidate --provider`, Codex auto-redeem, a saved-reset redeem) wiping the broker's cached usage for every provider, so other accounts showed no usage on every client until their next successful probe ([#14761](https://github.com/can1357/oh-my-pi/pull/14761) by [@will-bogusz](https://github.com/will-bogusz))
18
+ - Fixed Anthropic sessions missing the prompt cache a second time after the API dropped thinking blocks from a changed conversation ([#14748](https://github.com/can1357/oh-my-pi/pull/14748) by [@will-bogusz](https://github.com/will-bogusz))
19
+ - Fixed Cursor logins never renewing: every refresh was rejected with "Invalid User API Key", so logins lapsed about 60 days after sign-in; a session Cursor has ended is now disabled with a re-login hint ([#14753](https://github.com/can1357/oh-my-pi/pull/14753) by [@will-bogusz](https://github.com/will-bogusz))
20
+ - Fixed the auth broker logging caller-supplied `X-Forwarded-For` / `X-Real-IP` values and unknown request paths; peers come from the socket unless `trustProxyHeaders` is set ([#14762](https://github.com/can1357/oh-my-pi/pull/14762) by [@will-bogusz](https://github.com/will-bogusz))
21
+ - Fixed false thinking-loop detections on Gemini, DeepSeek, and Grok when the model drafts or answers with repetitive code or markup (VRML, SVG, JSON); such output is no longer discarded and retried.
22
+ - Fixed Cursor provider leaking conversation checkpoints, blob stores and abort listeners after requests end ([#14669](https://github.com/can1357/oh-my-pi/pull/14669) by [@H4vC](https://github.com/H4vC))
23
+ - Fixed session-affinity pins growing without bound in long-lived gateways (now capped at 256 sessions per provider) ([#14672](https://github.com/can1357/oh-my-pi/pull/14672) by [@H4vC](https://github.com/H4vC))
24
+ - Fixed completed Cursor turns failing with "Cursor stream ended before turnEnded" when the connection closed after the answer had fully arrived ([#14851](https://github.com/can1357/oh-my-pi/pull/14851) by [@kyle-elliott-asymptote](https://github.com/kyle-elliott-asymptote)).
25
+ - Fixed Cursor provider errors that Cursor marks as not retryable being retried until the retry budget ran out ([#14851](https://github.com/can1357/oh-my-pi/pull/14851) by [@kyle-elliott-asymptote](https://github.com/kyle-elliott-asymptote)).
26
+
27
+ ## [18.8.3] - 2026-10-07
28
+
29
+ ### Added
30
+
31
+ - Added `OAuthRefreshUnavailableError`, a retryable error that `keys.getWithCredential` and `oauth.access` reject with when every usable OAuth credential failed to refresh transiently; `keys.get` resolves `undefined` instead so availability probes move on to their next candidate ([#14843](https://github.com/can1357/oh-my-pi/pull/14843) by [@H4vC](https://github.com/H4vC))
32
+
33
+ ### Fixed
34
+
35
+ - Fixed a single transient OAuth token-refresh failure (network blip, timeout, 5xx) ending a running session, including subagents restricted to an account pool, with a non-retryable "No API key for provider" error while the stored credential was still valid; the refresh error now surfaces and the request is retried ([#14843](https://github.com/can1357/oh-my-pi/pull/14843) by [@H4vC](https://github.com/H4vC))
36
+
5
37
  ## [18.8.1] - 2026-10-07
6
38
 
7
39
  ### Added
@@ -2297,67 +2329,4 @@
2297
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))
2298
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)).
2299
2331
 
2300
- ## [16.0.0] - 2026-06-15
2301
-
2302
- ### Breaking Changes
2303
-
2304
- - Renamed the public dialect entrypoint from `@oh-my-pi/pi-ai/grammar` to `@oh-my-pi/pi-ai/dialect`.
2305
- - Renamed grammar dialect identifiers from `ToolCallSyntax` to `Dialect`, renamed the `Grammar` interface to `DialectDefinition`, and renamed `Grammar.syntax` to `DialectDefinition.dialect`.
2306
- - Added `DialectDefinition.renderThinking` and `DialectDefinition.renderTranscript` so dialect implementations serialize complete native chat transcripts, not just tool call/result blocks.
2307
-
2308
- ### Added
2309
-
2310
- - Added `renderTranscript` method to dialect definitions for serializing complete native chat transcripts
2311
- - Added `renderThinking` method to dialect definitions for rendering thinking/reasoning blocks
2312
- - Added support for 11 dialect implementations: Anthropic, DeepSeek, Gemini, Gemma, GLM, Harmony, Hermes, Kimi, Pi-native, Qwen3, and XML
2313
- - Added `createInbandScanner` factory function to instantiate dialect-specific scanners
2314
- - Added `getDialectDefinition` function to retrieve dialect implementations by name
2315
- - Added `renderToolCatalog` and `renderInbandToolPrompt` functions for tool catalog rendering
2316
- - Added `renderToolInventory` function to generate human-readable per-tool documentation with examples
2317
- - Added `renderToolExamples` function to render tool usage examples in the model's native dialect
2318
- - Added `encodeInbandToolHistory` function to encode tool call history in dialect-specific format
2319
- - Added `wrapInbandToolStream` function to process streaming responses with in-band tool call parsing
2320
- - Added `ThinkingInbandScanner` for parsing thinking/reasoning blocks across dialects
2321
- - Added `OwnedStream` class for managing dialect-aware streaming with tool call events
2322
- - Added in-band thinking channels to every dialect that was missing one: `gemini` (a ` ```thinking ` fence mirroring ` ```tool_code `), `gemma` (its native `<|channel>thought…<channel|>` reasoning channel), `kimi` (`<think>…</think>`), and `pi` (`<thinking>…</thinking>`). Each scanner now parses reasoning into thinking events instead of leaking chain-of-thought into the visible reply, and every dialect's `renderThinking` is a real channel that round-trips back through its scanner (no passthrough renderers).
2323
-
2324
- ### Changed
2325
-
2326
- - Moved public dialect entrypoint from `@oh-my-pi/pi-ai/grammar` to `@oh-my-pi/pi-ai/dialect` in package exports
2327
- - Updated internal imports in `stream-markup-healing.ts` to use new dialect module path
2328
- - Changed `renderToolInventory` to demote a tool description's own markdown headers by one level when it contains a top-level `# ` header, so they nest under the wrapping `# Tool: <name>` heading instead of reading as sibling sections. Descriptions that already start at `##` and headers inside fenced code blocks are left untouched.
2329
-
2330
- ### Fixed
2331
-
2332
- - Fixed Gemini, Gemma, Kimi, and Pi in-band scanners to respect `parseThinking: false`, leaving private reasoning markers in visible text when parsing is disabled
2333
- - Fixed thinking-channel parsing for streaming Gemini, Gemma, Kimi, and Pi outputs so split or partial `<thinking>` blocks no longer leak into visible replies
2334
- - Fixed in-band thinking finalization and Kimi stream-healing interactions so leaked `<think>` blocks are preserved when structured tool calls are present, not duplicated when explicit reasoning is present, and closed on stream flush.
2335
-
2336
- ### Removed
2337
-
2338
- - Removed `src/grammar/factory.ts` (replaced by `src/dialect/factory.ts`)
2339
- - Removed `src/grammar/rendering.ts` (functionality moved to `src/dialect/rendering.ts`)
2340
- - Removed `src/grammar/xml.ts` (replaced by `src/dialect/xml.ts`)
2341
-
2342
- ## [15.13.3] - 2026-06-15
2343
-
2344
- ### Added
2345
-
2346
- - Added the `gemini` in-band tool-call syntax with Python-style `tool_code` blocks and `default_api` invocations
2347
- - Added the `gemma` token-delimited in-band tool-call syntax using `<|tool_call>` and `<|tool_response>` blocks
2348
- - Added `gemini` and `gemma` to owned stream tool-result token detection so their tool responses are recognized
2349
- - Fixed truncated Gemini and Gemma tool blocks from being emitted as plain text during streaming
2350
- - Added the Azure OpenAI provider definition (`azure`) to the registry; `AZURE_OPENAI_API_KEY` resolves as its env-var API key via the catalog provider table.
2351
-
2352
- ### Changed
2353
-
2354
- - Gemini tool-call examples now render without the `default_api.` namespace prefix, keeping `<example>` blocks concise. The live wire format still uses `default_api.` per the Gemini grammar.
2355
-
2356
- ### Fixed
2357
-
2358
- - Fixed duplicate tool call projections by deduplicating provider-native `toolCall` events against in-band `tool_code` calls and keeping only the first real channel
2359
- - Dropped nameless native `toolCall` events so they no longer appear as surfaced tool calls in owned-mode streams
2360
- - Fixed truncated Gemini and Gemma tool blocks from being emitted as plain text during streaming
2361
- - Fixed Gemini/Gemma in-band tool-call parsing around Python comments, raw/unicode string literals, and Gemma close-token text inside string values.
2362
-
2363
- Older entries are archived in [packages/ai/CHANGELOG.md@a20cc0d04b64](https://github.com/can1357/oh-my-pi/blob/a20cc0d04b64f4a68fcc559cbd258743d984c50e/packages/ai/CHANGELOG.md).
2332
+ Older entries are archived in [packages/ai/CHANGELOG.md@d22e34333695](https://github.com/can1357/oh-my-pi/blob/d22e34333695d3dd9de7741ff45903681dba4606/packages/ai/CHANGELOG.md).
@@ -2,7 +2,7 @@ import { type ApiKeyResolver, type ResolvedApiKey } from "../auth-retry.js";
2
2
  import type { SessionAffinity } from "./affinity.js";
3
3
  import type { CredentialPool } from "./pool.js";
4
4
  import type { CredentialSelector } from "./select.js";
5
- import type { AuthApiKeyOptions, AuthCredential, AuthSource, AuthSourceOptions, KeysApi, LimitsApi, OAuthRequestIdentity } from "./types.js";
5
+ import type { AuthApiKeyOptions, AuthCredential, AuthSource, AuthSourceOptions, KeysApi, LimitsApi } from "./types.js";
6
6
  /** Runtime (--api-key) and config (models.yml) key overrides plus the config-value resolver. */
7
7
  export declare class KeyOverrides {
8
8
  #private;
@@ -117,7 +117,13 @@ export declare class KeyCascade implements KeysApi {
117
117
  * credential to reach the correct host.
118
118
  */
119
119
  peek(provider: string): Promise<string | undefined>;
120
- /** Resolve a bearer together with the stored row that supplied it. */
120
+ /**
121
+ * Resolve a bearer together with the stored row that supplied it, for a request.
122
+ * Same precedence as {@link KeyCascade.get}, but rejects with
123
+ * {@link AIError.OAuthRefreshUnavailableError} (transient, retryable) when OAuth
124
+ * refresh failed with a retryable error and no other permitted source could serve
125
+ * the request, so request paths retry instead of reporting a missing key.
126
+ */
121
127
  getWithCredential(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<ResolvedApiKey | undefined>;
122
128
  /**
123
129
  * Get API key for a provider.
@@ -127,8 +133,11 @@ export declare class KeyCascade implements KeysApi {
127
133
  * OAuth accounts and throws {@link AIError.MissingApiKeyError} when none
128
134
  * can serve or a config key (a models.yml `apiKey`, often for a proxy
129
135
  * `baseUrl`) owns the provider, rather than using any other credential.
136
+ * A transient OAuth refresh failure resolves `undefined`, so availability
137
+ * probes move on to their next candidate; request paths use
138
+ * {@link KeyCascade.getWithCredential}, which surfaces it.
130
139
  */
131
- get(provider: string, sessionId?: string, options?: AuthApiKeyOptions, onCredentialId?: (id: number, identity?: OAuthRequestIdentity) => void): Promise<string | undefined>;
140
+ get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
132
141
  /**
133
142
  * Build an {@link ApiKeyResolver} backed by this storage, implementing the
134
143
  * central a/b/c auth-retry policy:
@@ -37,9 +37,11 @@ export declare class OAuthAccounts implements OAuthApi {
37
37
  * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
38
38
  * scenarios, prefer API-key resolution.
39
39
  *
40
- * Returns `undefined` when no OAuth credential is available, the
41
- * credential fails to refresh, or runtime/config overrides have replaced
42
- * OAuth with an explicit API key.
40
+ * Returns `undefined` when no usable OAuth credential is available
41
+ * (none stored, or every one definitively failed to refresh) or
42
+ * runtime/config overrides have replaced OAuth with an explicit API key.
43
+ * Rejects with {@link AIError.OAuthRefreshUnavailableError} (transient,
44
+ * retryable) when a retryable refresh failure left no usable credential.
43
45
  */
44
46
  access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
45
47
  /**
@@ -32,6 +32,8 @@ export type TryOAuthOptions = {
32
32
  blockScopes?: readonly string[];
33
33
  /** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
34
34
  allowFallback?: boolean;
35
+ /** Receives a non-definitive refresh failure that left this credential unusable; the caller filters for retryable ones. */
36
+ onTransientRefreshFailure?: (error: unknown) => void;
35
37
  };
36
38
  /** Services consulted by CredentialSelector for policy, usage, blocks, refresh, and session affinity. */
37
39
  export interface CredentialSelectorDeps {
@@ -122,8 +122,8 @@ export interface UsageLedgerStore {
122
122
  }
123
123
  /** Broker-delegated OAuth refresh and usage-report operations. */
124
124
  export interface CredentialUpstream {
125
- /** Optional hook to notify the underlying store that usage report cache is stale. */
126
- invalidateUsageCache(signal?: AbortSignal): Promise<void>;
125
+ /** Optional hook to notify the underlying store that usage reports (one provider's, when given) are stale. */
126
+ invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
127
127
  /**
128
128
  * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
129
129
  * it before the per-provider local refresh path. `RemoteAuthCredentialStore`
@@ -812,9 +812,20 @@ export interface KeysApi {
812
812
  * 4. API key persisted by a successful `/login`
813
813
  * 5. Environment variable
814
814
  * 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
815
+ *
816
+ * Resolves `undefined` when no permitted credential can serve, including when
817
+ * OAuth refresh failed transiently (network, timeout, 5xx), so availability
818
+ * probes move on to their next candidate. Request paths use
819
+ * {@link KeysApi.getWithCredential}, which surfaces that failure.
815
820
  */
816
821
  get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
817
- /** Resolve a bearer together with its durable stored credential row id, when known. */
822
+ /**
823
+ * Resolve a bearer together with its durable stored credential row id, when known.
824
+ * Same precedence as {@link KeysApi.get}, but rejects with
825
+ * `OAuthRefreshUnavailableError` (transient, retryable) when OAuth refresh failed
826
+ * with a retryable error and no other permitted source could serve the request;
827
+ * the stored credential is kept.
828
+ */
818
829
  getWithCredential(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<ResolvedApiKey | undefined>;
819
830
  /**
820
831
  * Peek at API key for a provider without refreshing OAuth tokens.
@@ -935,9 +946,11 @@ export interface OAuthApi {
935
946
  * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
936
947
  * scenarios, prefer {@link AuthStorage.keys.get}.
937
948
  *
938
- * Returns `undefined` when no OAuth credential is available, the
939
- * credential fails to refresh, or runtime/config overrides have replaced
940
- * OAuth with an explicit API key.
949
+ * Returns `undefined` when no usable OAuth credential is available (none
950
+ * stored, or every one definitively failed to refresh) or runtime/config
951
+ * overrides have replaced OAuth with an explicit API key. Rejects with
952
+ * `OAuthRefreshUnavailableError` (transient, retryable) when a retryable
953
+ * refresh failure left no usable credential.
941
954
  */
942
955
  access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
943
956
  /**
@@ -85,7 +85,7 @@ export declare class AuthBrokerClient {
85
85
  fetchClientUsageSummary(query?: {
86
86
  sinceMs?: number;
87
87
  }, signal?: AbortSignal): Promise<ClientUsageSummaryResponse>;
88
- notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse>;
88
+ notifyUsageStale(provider?: string, signal?: AbortSignal): Promise<UsageStaleResponse>;
89
89
  refreshCredential(id: number, signal?: AbortSignal, reason?: OAuthRefreshReason): Promise<CredentialRefreshResponse>;
90
90
  disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse>;
91
91
  /**
@@ -122,7 +122,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
122
122
  /** Drop all cache rows whose keys start with the supplied prefix. */
123
123
  deleteCachePrefix(prefix: string): void;
124
124
  cleanExpiredCache(): void;
125
- invalidateUsageCache(signal?: AbortSignal): Promise<void>;
125
+ invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
126
126
  /**
127
127
  * Store-level hook consumed by `AuthStorage` — routes refresh through the
128
128
  * broker so the actual refresh token never leaves the broker host. Returns
@@ -17,6 +17,8 @@ export interface AuthBrokerServerOptions {
17
17
  bind?: string;
18
18
  /** Accept any of these bearer tokens. Empty disables auth (loopback only). */
19
19
  bearerTokens: string[];
20
+ /** Honor forwarded peer headers only when the connecting proxy is trusted. Default false. */
21
+ trustProxyHeaders?: boolean;
20
22
  /** Broker version string surfaced on `/v1/healthz`. */
21
23
  version?: string;
22
24
  /** Refresh credentials expiring within this window. Default 5 min. */
@@ -17,6 +17,8 @@ export interface AuthGatewayRouteOptions {
17
17
  resolveModel: ModelResolver;
18
18
  /** Optional supplier for `/v1/models` listing. Returns the full model array. */
19
19
  listModels?: () => Iterable<Model<Api>>;
20
+ /** Providers the host does not serve; `/v1/usage` and `/v1/credentials/check` leave their accounts out. */
21
+ excludeProviders?: ReadonlySet<string>;
20
22
  /** Upstream transport for every provider call; defaults to global `fetch`. Test seam. */
21
23
  fetch?: FetchImpl;
22
24
  }
@@ -16,8 +16,6 @@ export declare function gatewayResponseHeaders(model: Model<Api>, info: {
16
16
  costUsd?: number;
17
17
  startedAt?: number;
18
18
  }): Record<string, string>;
19
- /** Use the socket peer unless the gateway explicitly trusts its reverse proxy. */
20
- export declare function resolvePeer(req: Request, socketAddress: string, trustProxyHeaders?: boolean): string;
21
19
  /** Keep configured gateway credentials out of URL and forwarded/logged request fields. */
22
20
  export declare function hasMisplacedBearer(req: Request, url: URL, tokens: ReadonlySet<string>): boolean;
23
21
  /**
@@ -10,6 +10,16 @@ export declare class MissingApiKeyError extends Error {
10
10
  readonly provider: string | undefined;
11
11
  constructor(provider?: string, message?: string);
12
12
  }
13
+ /**
14
+ * Every usable OAuth credential for a provider failed to refresh with a
15
+ * retryable error (network blip, timeout, 5xx). The stored credentials are
16
+ * intact, so this is classified transient: retrying the request can succeed,
17
+ * unlike {@link MissingApiKeyError}. The refresh failure is kept as `cause`.
18
+ */
19
+ export declare class OAuthRefreshUnavailableError extends Error {
20
+ readonly provider: string;
21
+ constructor(provider: string, cause: unknown);
22
+ }
13
23
  /** A user-facing login flow required an `onPrompt` callback that was not supplied. */
14
24
  export declare class OnPromptRequiredError extends Error {
15
25
  constructor(providerLabel: string);
@@ -18,8 +18,8 @@
18
18
  * siblings are endpoint-scoped and stay: `strictToolsDisabled`
19
19
  * (grammar-too-large 400 for the model's tool schema),
20
20
  * `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
21
- * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks the API itself
22
- * dropped).
21
+ * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a prefix-binding
22
+ * 400 made the client strip).
23
23
  * - **OpenAI Responses** — the `previous_response_id` chain baselines are
24
24
  * account-scoped: a stored response belongs to the account that created it.
25
25
  * Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
@@ -13,7 +13,7 @@ export declare function pollCursorAuth(uuid: string, verifier: string): Promise<
13
13
  }>;
14
14
  export declare function loginCursor(onAuthUrl: (url: string) => void, onPollStart?: () => void): Promise<OAuthCredentials>;
15
15
  export declare function loginCursorHook(callbacks: OAuthController): Promise<OAuthCredentials>;
16
- export declare function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<OAuthCredentials>;
16
+ export declare function refreshCursorToken(refreshToken: string): Promise<OAuthCredentials>;
17
17
  export declare function refreshCursorHook(credentials: OAuthCredentials, signal?: AbortSignal): Promise<OAuthCredentials>;
18
18
  /** Request headers that present `accessToken` to cursor.com as its user's web session. */
19
19
  export declare function cursorSessionHeaders(userId: string, accessToken: string): Record<string, string>;
@@ -0,0 +1,2 @@
1
+ /** Use the socket peer unless the server explicitly trusts its reverse proxy. */
2
+ export declare function resolvePeer(req: Request, socketAddress: string, trustProxyHeaders?: boolean): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.8.2",
3
+ "version": "18.8.4",
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.2",
159
- "@oh-my-pi/pi-catalog": "18.8.2",
160
- "@oh-my-pi/pi-natives": "18.8.2",
161
- "@oh-my-pi/pi-utils": "18.8.2",
162
- "@oh-my-pi/pi-wire": "18.8.2"
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"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -1,5 +1,5 @@
1
1
  import { authPolicyFor } from "@oh-my-pi/pi-catalog/compat/auth";
2
- import { $env, $envExact } from "@oh-my-pi/pi-utils";
2
+ import { $env, $envExact, logger } from "@oh-my-pi/pi-utils";
3
3
  import { type ApiKeyResolver, markAfterSiblingWait, type ResolvedApiKey } from "../auth-retry";
4
4
  import { getEnvApiKey, getEnvApiKeyName } from "../env-api-key";
5
5
  import * as AIError from "../error";
@@ -7,7 +7,7 @@ import { isUsageLimitOutcome } from "../error/rate-limit";
7
7
  import { AUTHENTICATED_SENTINEL } from "../registry/types";
8
8
  import type { SessionAffinity } from "./affinity";
9
9
  import type { CredentialPool } from "./pool";
10
- import type { CredentialSelector } from "./select";
10
+ import type { CredentialSelector, OAuthResolutionResult } from "./select";
11
11
  import type {
12
12
  AuthApiKeyOptions,
13
13
  AuthCredential,
@@ -308,7 +308,13 @@ export class KeyCascade implements KeysApi {
308
308
  return undefined;
309
309
  }
310
310
 
311
- /** Resolve a bearer together with the stored row that supplied it. */
311
+ /**
312
+ * Resolve a bearer together with the stored row that supplied it, for a request.
313
+ * Same precedence as {@link KeyCascade.get}, but rejects with
314
+ * {@link AIError.OAuthRefreshUnavailableError} (transient, retryable) when OAuth
315
+ * refresh failed with a retryable error and no other permitted source could serve
316
+ * the request, so request paths retry instead of reporting a missing key.
317
+ */
312
318
  async getWithCredential(
313
319
  provider: string,
314
320
  sessionId?: string,
@@ -316,7 +322,7 @@ export class KeyCascade implements KeysApi {
316
322
  ): Promise<ResolvedApiKey | undefined> {
317
323
  let credentialId: number | undefined;
318
324
  let oauthIdentity: OAuthRequestIdentity | undefined;
319
- const apiKey = await this.get(provider, sessionId, options, (id, identity) => {
325
+ const apiKey = await this.#resolve(provider, sessionId, options, (id, identity) => {
320
326
  credentialId = id;
321
327
  oauthIdentity = identity;
322
328
  });
@@ -331,8 +337,21 @@ export class KeyCascade implements KeysApi {
331
337
  * OAuth accounts and throws {@link AIError.MissingApiKeyError} when none
332
338
  * can serve or a config key (a models.yml `apiKey`, often for a proxy
333
339
  * `baseUrl`) owns the provider, rather than using any other credential.
340
+ * A transient OAuth refresh failure resolves `undefined`, so availability
341
+ * probes move on to their next candidate; request paths use
342
+ * {@link KeyCascade.getWithCredential}, which surfaces it.
334
343
  */
335
- async get(
344
+ async get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
345
+ try {
346
+ return await this.#resolve(provider, sessionId, options);
347
+ } catch (error) {
348
+ if (!(error instanceof AIError.OAuthRefreshUnavailableError)) throw error;
349
+ logger.debug("API key probe skipped transient OAuth refresh failure", { provider, error: error.message });
350
+ return undefined;
351
+ }
352
+ }
353
+
354
+ async #resolve(
336
355
  provider: string,
337
356
  sessionId?: string,
338
357
  options?: AuthApiKeyOptions,
@@ -363,7 +382,16 @@ export class KeyCascade implements KeysApi {
363
382
 
364
383
  // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
365
384
  // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
366
- const oauthResolved = await this.#deps.selector.resolveOAuth(provider, sessionId, options);
385
+ // A transient OAuth refresh failure still lets later sources serve the request; it is
386
+ // rethrown only when none can, so callers retry instead of reporting a missing key.
387
+ let oauthResolved: OAuthResolutionResult | undefined;
388
+ let oauthRefreshFailure: AIError.OAuthRefreshUnavailableError | undefined;
389
+ try {
390
+ oauthResolved = await this.#deps.selector.resolveOAuth(provider, sessionId, options);
391
+ } catch (error) {
392
+ if (!(error instanceof AIError.OAuthRefreshUnavailableError)) throw error;
393
+ oauthRefreshFailure = error;
394
+ }
367
395
  if (oauthResolved) {
368
396
  if (onCredentialId && oauthResolved.credentialId !== undefined) {
369
397
  const { orgId, region, inferenceRegion } = oauthResolved.credential;
@@ -372,6 +400,7 @@ export class KeyCascade implements KeysApi {
372
400
  return oauthResolved.apiKey;
373
401
  }
374
402
  if (restricted) {
403
+ if (oauthRefreshFailure) throw oauthRefreshFailure;
375
404
  throw new AIError.MissingApiKeyError(
376
405
  provider,
377
406
  `No API key for provider: ${provider} (session ${sessionId} is restricted to its OAuth account pool and none of those accounts is available)`,
@@ -420,6 +449,7 @@ export class KeyCascade implements KeysApi {
420
449
  if (apiKey !== undefined && credentialId !== undefined) onCredentialId?.(credentialId);
421
450
  return apiKey;
422
451
  }
452
+ if (oauthRefreshFailure) throw oauthRefreshFailure;
423
453
  return undefined;
424
454
  }
425
455
 
package/src/auth/oauth.ts CHANGED
@@ -127,9 +127,11 @@ export class OAuthAccounts implements OAuthApi {
127
127
  * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
128
128
  * scenarios, prefer API-key resolution.
129
129
  *
130
- * Returns `undefined` when no OAuth credential is available, the
131
- * credential fails to refresh, or runtime/config overrides have replaced
132
- * OAuth with an explicit API key.
130
+ * Returns `undefined` when no usable OAuth credential is available
131
+ * (none stored, or every one definitively failed to refresh) or
132
+ * runtime/config overrides have replaced OAuth with an explicit API key.
133
+ * Rejects with {@link AIError.OAuthRefreshUnavailableError} (transient,
134
+ * retryable) when a retryable refresh failure left no usable credential.
133
135
  */
134
136
  async access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined> {
135
137
  // Runtime / config overrides intentionally short-circuit OAuth: when the
@@ -262,7 +262,7 @@ export class ResetCredits implements ResetsApi {
262
262
  resetSpentCredentialId: access.credentialId,
263
263
  });
264
264
  if (this.#deps.store.invalidateUsageCache) {
265
- await this.#deps.store.invalidateUsageCache(options.signal).catch(err => {
265
+ await this.#deps.store.invalidateUsageCache(provider, options.signal).catch(err => {
266
266
  logger.debug("Failed to notify store of stale usage", { err });
267
267
  });
268
268
  }
@@ -65,6 +65,8 @@ export type TryOAuthOptions = {
65
65
  blockScopes?: readonly string[];
66
66
  /** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
67
67
  allowFallback?: boolean;
68
+ /** Receives a non-definitive refresh failure that left this credential unusable; the caller filters for retryable ones. */
69
+ onTransientRefreshFailure?: (error: unknown) => void;
68
70
  };
69
71
 
70
72
  /** Services consulted by CredentialSelector for policy, usage, blocks, refresh, and session affinity. */
@@ -653,6 +655,14 @@ export class CredentialSelector {
653
655
  : { selection, usage: null, usageChecked: false },
654
656
  );
655
657
  const preflightFailures = new Set<OAuthCandidate>();
658
+ // The last retryable refresh error (network, timeout, 5xx) that removed a candidate.
659
+ // When no candidate resolves, it is rethrown so callers retry instead of reporting
660
+ // a missing key. Refresher outcomes after a dead grant (row disabled, CAS lost) are
661
+ // classified auth failures and keep resolving to undefined.
662
+ let transientRefreshFailure: unknown;
663
+ const recordTransientRefreshFailure = (error: unknown): void => {
664
+ if (AIError.retriable(AIError.classify(error))) transientRefreshFailure = error;
665
+ };
656
666
 
657
667
  const sessionPreferredCandidate = candidates.findIndex(
658
668
  candidate =>
@@ -799,16 +809,21 @@ export class CredentialSelector {
799
809
  // to a sibling account with the wrong prefetched usage/plan.
800
810
  return;
801
811
  }
802
- } else if (credentialId !== undefined) {
803
- const latestIndex = this.#deps.pool.entries(provider).findIndex(entry => entry.id === credentialId);
804
- if (latestIndex !== -1) {
805
- this.#deps.blocks.mark(
806
- provider,
807
- providerKey,
808
- latestIndex,
809
- Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
810
- AUTH_BLOCK_SCOPE,
811
- );
812
+ } else {
813
+ recordTransientRefreshFailure(error);
814
+ if (credentialId !== undefined) {
815
+ const latestIndex = this.#deps.pool
816
+ .entries(provider)
817
+ .findIndex(entry => entry.id === credentialId);
818
+ if (latestIndex !== -1) {
819
+ this.#deps.blocks.mark(
820
+ provider,
821
+ providerKey,
822
+ latestIndex,
823
+ Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
824
+ AUTH_BLOCK_SCOPE,
825
+ );
826
+ }
812
827
  }
813
828
  }
814
829
  preflightFailures.add(candidate);
@@ -882,11 +897,15 @@ export class CredentialSelector {
882
897
  rankingContext,
883
898
  blockScope,
884
899
  blockScopes,
900
+ onTransientRefreshFailure: recordTransientRefreshFailure,
885
901
  });
886
902
  if (resolved) return resolved;
887
903
  }
888
904
  }
889
905
 
906
+ if (transientRefreshFailure !== undefined) {
907
+ throw new AIError.OAuthRefreshUnavailableError(provider, transientRefreshFailure);
908
+ }
890
909
  return undefined;
891
910
  }
892
911
 
@@ -1117,6 +1136,7 @@ export class CredentialSelector {
1117
1136
  if (allowFallback) return this.resolveOAuth(provider, sessionId, options);
1118
1137
  }
1119
1138
  } else {
1139
+ usageOptions.onTransientRefreshFailure?.(error);
1120
1140
  // Block temporarily for transient failures (5 minutes)
1121
1141
  this.#deps.blocks.mark(
1122
1142
  provider,
package/src/auth/store.ts CHANGED
@@ -152,8 +152,8 @@ export interface UsageLedgerStore {
152
152
 
153
153
  /** Broker-delegated OAuth refresh and usage-report operations. */
154
154
  export interface CredentialUpstream {
155
- /** Optional hook to notify the underlying store that usage report cache is stale. */
156
- invalidateUsageCache(signal?: AbortSignal): Promise<void>;
155
+ /** Optional hook to notify the underlying store that usage reports (one provider's, when given) are stale. */
156
+ invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
157
157
  /**
158
158
  * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
159
159
  * it before the per-provider local refresh path. `RemoteAuthCredentialStore`
package/src/auth/types.ts CHANGED
@@ -894,9 +894,20 @@ export interface KeysApi {
894
894
  * 4. API key persisted by a successful `/login`
895
895
  * 5. Environment variable
896
896
  * 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
897
+ *
898
+ * Resolves `undefined` when no permitted credential can serve, including when
899
+ * OAuth refresh failed transiently (network, timeout, 5xx), so availability
900
+ * probes move on to their next candidate. Request paths use
901
+ * {@link KeysApi.getWithCredential}, which surfaces that failure.
897
902
  */
898
903
  get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
899
- /** Resolve a bearer together with its durable stored credential row id, when known. */
904
+ /**
905
+ * Resolve a bearer together with its durable stored credential row id, when known.
906
+ * Same precedence as {@link KeysApi.get}, but rejects with
907
+ * `OAuthRefreshUnavailableError` (transient, retryable) when OAuth refresh failed
908
+ * with a retryable error and no other permitted source could serve the request;
909
+ * the stored credential is kept.
910
+ */
900
911
  getWithCredential(
901
912
  provider: string,
902
913
  sessionId?: string,
@@ -1017,9 +1028,11 @@ export interface OAuthApi {
1017
1028
  * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
1018
1029
  * scenarios, prefer {@link AuthStorage.keys.get}.
1019
1030
  *
1020
- * Returns `undefined` when no OAuth credential is available, the
1021
- * credential fails to refresh, or runtime/config overrides have replaced
1022
- * OAuth with an explicit API key.
1031
+ * Returns `undefined` when no usable OAuth credential is available (none
1032
+ * stored, or every one definitively failed to refresh) or runtime/config
1033
+ * overrides have replaced OAuth with an explicit API key. Rejects with
1034
+ * `OAuthRefreshUnavailableError` (transient, retryable) when a retryable
1035
+ * refresh failure left no usable credential.
1023
1036
  */
1024
1037
  access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
1025
1038
  /**
package/src/auth/usage.ts CHANGED
@@ -871,7 +871,7 @@ export class UsageService implements UsageApi {
871
871
  await this.#deps.cache.clearReports(provider, () => this.#collectUsageRequests());
872
872
 
873
873
  if (this.#deps.store.invalidateUsageCache) {
874
- await this.#deps.store.invalidateUsageCache(signal).catch(err => {
874
+ await this.#deps.store.invalidateUsageCache(provider, signal).catch(err => {
875
875
  logger.debug("Failed to notify store of stale usage", { err });
876
876
  });
877
877
  }
@@ -309,8 +309,11 @@ export class AuthBrokerClient {
309
309
  });
310
310
  }
311
311
 
312
- notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse> {
313
- return this.#request<UsageStaleResponse>("POST", "/v1/usage/stale", {
312
+ notifyUsageStale(provider?: string, signal?: AbortSignal): Promise<UsageStaleResponse> {
313
+ const params = new URLSearchParams();
314
+ if (provider) params.set("provider", provider);
315
+ const path = `/v1/usage/stale${params.size > 0 ? `?${params.toString()}` : ""}`;
316
+ return this.#request<UsageStaleResponse>("POST", path, {
314
317
  schema: "usageStaleResponseSchema",
315
318
  signal,
316
319
  });
@@ -1196,11 +1196,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1196
1196
  }
1197
1197
  }
1198
1198
 
1199
- async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
1199
+ async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
1200
1200
  this.#noteActivity();
1201
1201
  this.#invalidateUsageCache();
1202
1202
  try {
1203
- await this.#client.notifyUsageStale(signal);
1203
+ await this.#client.notifyUsageStale(provider, signal);
1204
1204
  } catch (err) {
1205
1205
  logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
1206
1206
  } finally {
@@ -14,6 +14,7 @@ import { type Type, type } from "@oh-my-pi/omptype";
14
14
  import { logger } from "@oh-my-pi/pi-utils";
15
15
  import type { AuthCredentialSnapshotEntry, AuthStorage, StoredCredentialBlock } from "../auth-storage";
16
16
  import { parseBind } from "../utils/parse-bind";
17
+ import { resolvePeer } from "../utils/resolve-peer";
17
18
  import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher";
18
19
  import type {
19
20
  ClientUsageReportRequest,
@@ -62,6 +63,8 @@ export interface AuthBrokerServerOptions {
62
63
  bind?: string;
63
64
  /** Accept any of these bearer tokens. Empty disables auth (loopback only). */
64
65
  bearerTokens: string[];
66
+ /** Honor forwarded peer headers only when the connecting proxy is trusted. Default false. */
67
+ trustProxyHeaders?: boolean;
65
68
  /** Broker version string surfaced on `/v1/healthz`. */
66
69
  version?: string;
67
70
  /** Refresh credentials expiring within this window. Default 5 min. */
@@ -156,6 +159,24 @@ const REFRESH_ROUTE = /^\/v1\/credential\/(\d+)\/refresh$/;
156
159
  const DISABLE_ROUTE = /^\/v1\/credential\/(\d+)\/disable$/;
157
160
  const BLOCK_ROUTE = /^\/v1\/credential\/(\d+)\/block$/;
158
161
  const BLOCKS_ROUTE = /^\/v1\/credential\/(\d+)\/blocks$/;
162
+ const STATIC_ROUTES: Record<string, true> = {
163
+ "/v1/snapshot/stream": true,
164
+ "/v1/snapshot": true,
165
+ "/v1/usage": true,
166
+ "/v1/usage/history": true,
167
+ "/v1/usage/observed": true,
168
+ "/v1/usage/clients": true,
169
+ "/v1/usage/stale": true,
170
+ "/v1/credentials/disabled": true,
171
+ "/v1/credential": true,
172
+ };
173
+
174
+ /** Only known routes reach logs verbatim; unknown paths may carry caller-supplied secrets. */
175
+ function loggablePath(pathname: string): string {
176
+ if (Object.hasOwn(STATIC_ROUTES, pathname)) return pathname;
177
+ const dynamic = [REFRESH_ROUTE, DISABLE_ROUTE, BLOCK_ROUTE, BLOCKS_ROUTE].some(route => route.test(pathname));
178
+ return dynamic ? pathname : "<unrouted>";
179
+ }
159
180
 
160
181
  const MAX_SNAPSHOT_WAIT_MS = 30_000;
161
182
  const DISABLED_NEXT_SWEEP_IN_MS = Number.MAX_SAFE_INTEGER;
@@ -808,20 +829,24 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
808
829
  hostname: bind.hostname,
809
830
  port: bind.port,
810
831
  idleTimeout: DEFAULT_SERVER_IDLE_TIMEOUT_S,
811
- fetch: async (req): Promise<Response> => {
832
+ fetch: async (req, server): Promise<Response> => {
812
833
  const url = new URL(req.url);
813
834
  const pathname = url.pathname;
814
- const peer =
815
- req.headers.get("x-forwarded-for")?.split(",")[0].trim() || req.headers.get("x-real-ip") || "unknown";
835
+ const socketPeer = server.requestIP(req)?.address ?? "unknown";
816
836
  try {
817
837
  if (req.method === "GET" && pathname === "/v1/healthz") {
818
838
  const body: HealthzResponse = { ok: true, version };
819
839
  return json(200, body);
820
840
  }
821
841
  if (!isAuthorized(req, tokens)) {
822
- logger.info("auth-broker request unauthorized", { method: req.method, path: pathname, peer });
842
+ logger.info("auth-broker request unauthorized", {
843
+ method: req.method,
844
+ path: loggablePath(pathname),
845
+ peer: socketPeer,
846
+ });
823
847
  return json(401, { error: "unauthorized" });
824
848
  }
849
+ const peer = resolvePeer(req, socketPeer, opts.trustProxyHeaders);
825
850
  if (req.method === "GET" && pathname === "/v1/snapshot/stream") {
826
851
  return serveSnapshotStream(req, snapshotSource, streamHub, peer, streamKeepaliveMs);
827
852
  }
@@ -887,9 +912,10 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
887
912
  return json(200, { generatedAt: Date.now(), clients: summary.clients });
888
913
  }
889
914
  if (req.method === "POST" && pathname === "/v1/usage/stale") {
915
+ const provider = url.searchParams.get("provider") || undefined;
890
916
  try {
891
- await opts.storage.usage.invalidate?.();
892
- logger.info("auth-broker usage cache invalidated", { peer });
917
+ await opts.storage.usage.invalidate?.(provider);
918
+ logger.info("auth-broker usage cache invalidated", { peer, provider });
893
919
  return json(200, { ok: true });
894
920
  } catch (error) {
895
921
  const message = error instanceof Error ? error.message : String(error);
@@ -1046,7 +1072,7 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
1046
1072
  } catch (error) {
1047
1073
  logger.error("auth-broker handler crashed", {
1048
1074
  method: req.method,
1049
- path: pathname,
1075
+ path: loggablePath(pathname),
1050
1076
  error: String(error),
1051
1077
  });
1052
1078
  return json(500, { error: "internal error" });
@@ -32,6 +32,8 @@ export interface AuthGatewayRouteOptions {
32
32
  resolveModel: ModelResolver;
33
33
  /** Optional supplier for `/v1/models` listing. Returns the full model array. */
34
34
  listModels?: () => Iterable<Model<Api>>;
35
+ /** Providers the host does not serve; `/v1/usage` and `/v1/credentials/check` leave their accounts out. */
36
+ excludeProviders?: ReadonlySet<string>;
35
37
  /** Upstream transport for every provider call; defaults to global `fetch`. Test seam. */
36
38
  fetch?: FetchImpl;
37
39
  }
@@ -51,14 +51,6 @@ export function gatewayResponseHeaders(
51
51
  return headers;
52
52
  }
53
53
 
54
- /** Use the socket peer unless the gateway explicitly trusts its reverse proxy. */
55
- export function resolvePeer(req: Request, socketAddress: string, trustProxyHeaders = false): string {
56
- if (!trustProxyHeaders) return socketAddress;
57
- const fwd = req.headers.get("x-forwarded-for");
58
- if (fwd) return fwd.split(",")[0].trim();
59
- return req.headers.get("x-real-ip") ?? socketAddress;
60
- }
61
-
62
54
  /**
63
55
  * Decode each run of percent-escapes on its own, so one malformed escape
64
56
  * elsewhere in the URL cannot hide an encoded token. A run that is not valid
@@ -31,7 +31,6 @@
31
31
  import { Effort } from "@oh-my-pi/pi-catalog/effort";
32
32
  import { type ModelKind, modelKind } from "@oh-my-pi/pi-catalog/types";
33
33
  import { logger } from "@oh-my-pi/pi-utils";
34
- import type { AuthStorage } from "../auth-storage";
35
34
  import { classifyGatewayError } from "../error/gateway";
36
35
  import * as anthropicMessages from "../providers/anthropic-messages-server";
37
36
  import * as openaiChat from "../providers/openai-chat-server";
@@ -41,6 +40,7 @@ import { completeSimple, streamSimple } from "../stream";
41
40
  import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
42
41
  import { deterministicUuid } from "../utils/deterministic-id";
43
42
  import { parseBind } from "../utils/parse-bind";
43
+ import { resolvePeer } from "../utils/resolve-peer";
44
44
  import {
45
45
  type AuthGatewayBootOptions,
46
46
  type AuthGatewayRouteOptions,
@@ -59,7 +59,6 @@ import {
59
59
  isAuthorized,
60
60
  json,
61
61
  resolveClientIdentity,
62
- resolvePeer,
63
62
  withCors,
64
63
  } from "./http";
65
64
  import { handleEmbeddings } from "./routes/embeddings";
@@ -709,12 +708,14 @@ async function handlePiNative(
709
708
  * failure) inside `AuthStorage`, so this handler is a thin wrapper that
710
709
  * surfaces the same data to HTTP callers (notably the macOS usage widget).
711
710
  */
712
- async function handleUsage(storage: AuthStorage, signal: AbortSignal): Promise<Response> {
713
- const reports = (await storage.usage.reports?.({ signal })) ?? [];
711
+ async function handleUsage(opts: AuthGatewayRouteOptions, signal: AbortSignal): Promise<Response> {
712
+ const reports = (await opts.storage.usage.reports?.({ signal })) ?? [];
714
713
  // Drop the heavy provider-specific `raw` payload — UI consumers only need
715
714
  // `limits` + `metadata`. Match the broker's `/v1/usage` shape so a single
716
715
  // client struct (Swift widget, llm-git, ...) works against either endpoint.
717
- const trimmed = reports.map(({ raw: _raw, ...rest }) => rest);
716
+ const trimmed = reports
717
+ .filter(report => !opts.excludeProviders?.has(report.provider))
718
+ .map(({ raw: _raw, ...rest }) => rest);
718
719
  return json(200, { generatedAt: Date.now(), reports: trimmed });
719
720
  }
720
721
 
@@ -729,8 +730,8 @@ async function handleUsage(storage: AuthStorage, signal: AbortSignal): Promise<R
729
730
  * endpoints. For multi-account pools that's the difference between getting
730
731
  * a clean diagnosis and getting a 429 storm.
731
732
  */
732
- async function handleCredentialsCheck(storage: AuthStorage, signal: AbortSignal): Promise<Response> {
733
- const credentials = await storage.health.check({ signal });
733
+ async function handleCredentialsCheck(opts: AuthGatewayRouteOptions, signal: AbortSignal): Promise<Response> {
734
+ const credentials = await opts.storage.health.check({ signal, excludeProviders: opts.excludeProviders });
734
735
  return json(200, { generatedAt: Date.now(), credentials });
735
736
  }
736
737
 
@@ -832,14 +833,14 @@ export function createAuthGatewayRouter(opts: AuthGatewayRouteOptions): AuthGate
832
833
  // Same shape as the broker's `/v1/usage`, so widget/llm-git speak to either with the
833
834
  // same client struct.
834
835
  if (req.method === "GET" && pathname === "/v1/usage") {
835
- return await handleUsage(opts.storage, req.signal);
836
+ return await handleUsage(opts, req.signal);
836
837
  }
837
838
 
838
839
  // Per-credential auth probe — diagnoses which row in a multi-account
839
840
  // pool is producing 401s. Aggregated `/v1/usage` silently drops failed
840
841
  // credentials, so we need a separate endpoint that captures errors.
841
842
  if (req.method === "GET" && pathname === "/v1/credentials/check") {
842
- return await handleCredentialsCheck(opts.storage, req.signal);
843
+ return await handleCredentialsCheck(opts, req.signal);
843
844
  }
844
845
 
845
846
  // Provider-format dispatch.
package/src/error/auth.ts CHANGED
@@ -19,6 +19,26 @@ export class MissingApiKeyError extends Error {
19
19
  }
20
20
  }
21
21
 
22
+ /**
23
+ * Every usable OAuth credential for a provider failed to refresh with a
24
+ * retryable error (network blip, timeout, 5xx). The stored credentials are
25
+ * intact, so this is classified transient: retrying the request can succeed,
26
+ * unlike {@link MissingApiKeyError}. The refresh failure is kept as `cause`.
27
+ */
28
+ export class OAuthRefreshUnavailableError extends Error {
29
+ readonly provider: string;
30
+
31
+ constructor(provider: string, cause: unknown) {
32
+ super(
33
+ `OAuth token refresh failed for provider ${provider}: ${cause instanceof Error ? cause.message : String(cause)}`,
34
+ { cause },
35
+ );
36
+ this.name = "OAuthRefreshUnavailableError";
37
+ this.provider = provider;
38
+ attach(this, create(Flag.Transient));
39
+ }
40
+ }
41
+
22
42
  /** A user-facing login flow required an `onPrompt` callback that was not supplied. */
23
43
  export class OnPromptRequiredError extends Error {
24
44
  constructor(providerLabel: string) {
@@ -18,8 +18,8 @@
18
18
  * siblings are endpoint-scoped and stay: `strictToolsDisabled`
19
19
  * (grammar-too-large 400 for the model's tool schema),
20
20
  * `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
21
- * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks the API itself
22
- * dropped).
21
+ * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a prefix-binding
22
+ * 400 made the client strip).
23
23
  * - **OpenAI Responses** — the `previous_response_id` chain baselines are
24
24
  * account-scoped: a stored response belongs to the account that created it.
25
25
  * Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
@@ -486,8 +486,14 @@ type AnthropicProviderSessionState = ProviderSessionState & {
486
486
  * this (baseUrl, modelId). Cleared on session close.
487
487
  */
488
488
  thinkingReplayDisabled: boolean;
489
- /** Thinking blocks the API permanently dropped after a prefix mismatch. */
489
+ /** Thinking blocks stripped after a prefix-binding 400 forced a retry without `drop_block`. */
490
490
  prefixDroppedThinkingBlocks: Set<string>;
491
+ /**
492
+ * Server-reported `prefix_binding_mismatch` drops already warned about. A
493
+ * `drop_block` request replays dropped blocks verbatim, so the API reports
494
+ * them again on every later request; only newly dropped blocks warn.
495
+ */
496
+ reportedPrefixDroppedThinking: Set<string>;
491
497
  };
492
498
 
493
499
  function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
@@ -497,12 +503,14 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
497
503
  replayUnsignedThinkingDisabled: false,
498
504
  thinkingReplayDisabled: false,
499
505
  prefixDroppedThinkingBlocks: new Set(),
506
+ reportedPrefixDroppedThinking: new Set(),
500
507
  close: () => {
501
508
  state.strictToolsDisabled = false;
502
509
  state.fastModeDisabled = false;
503
510
  state.replayUnsignedThinkingDisabled = false;
504
511
  state.thinkingReplayDisabled = false;
505
512
  state.prefixDroppedThinkingBlocks.clear();
513
+ state.reportedPrefixDroppedThinking.clear();
506
514
  },
507
515
  };
508
516
  return state;
@@ -518,6 +526,7 @@ function getAnthropicProviderSessionState(
518
526
  const existing = providerSessionState.get(key) as AnthropicProviderSessionState | undefined;
519
527
  if (existing) {
520
528
  existing.prefixDroppedThinkingBlocks ??= new Set();
529
+ existing.reportedPrefixDroppedThinking ??= new Set();
521
530
  return existing;
522
531
  }
523
532
  const created = createAnthropicProviderSessionState();
@@ -1943,12 +1952,33 @@ function rememberPrefixBindingFailure(
1943
1952
  return true;
1944
1953
  }
1945
1954
 
1955
+ /** Identify a reported drop by the dropped block's replay key, falling back to its wire path. */
1956
+ function reportedDropKey(params: MessageCreateParamsStreaming, path: string | undefined): string | undefined {
1957
+ if (path === undefined) return undefined;
1958
+ const match = INPUT_TRANSFORMATION_PATH_PATTERN.exec(path);
1959
+ if (match) {
1960
+ const content = params.messages[Number(match[1])]?.content;
1961
+ const block = Array.isArray(content) ? content[Number(match[2])] : undefined;
1962
+ const key = block ? thinkingReplayKey(block) : undefined;
1963
+ if (key) return key;
1964
+ }
1965
+ return `path:${path}`;
1966
+ }
1967
+
1968
+ /**
1969
+ * Record server-reported input rewrites on the response. A `drop_block`
1970
+ * request keeps replaying the transcript verbatim afterwards: the API drops
1971
+ * the same invalid blocks again on every later request (unbilled), while
1972
+ * omitting them client-side would change the bytes the drop request just
1973
+ * cached and can cost another message-prefix cache miss from the first
1974
+ * dropped block on. Repeat reports of an already-warned drop log at debug.
1975
+ */
1946
1976
  function applyReportedInputTransformations(
1947
1977
  output: AssistantMessage,
1948
- params: MessageCreateParamsStreaming,
1949
- state: AnthropicProviderSessionState | undefined,
1950
1978
  value: unknown,
1951
1979
  seen: Set<string>,
1980
+ params: MessageCreateParamsStreaming,
1981
+ state: AnthropicProviderSessionState | undefined,
1952
1982
  replace = false,
1953
1983
  ): void {
1954
1984
  if (value === undefined || value === null) return;
@@ -1965,14 +1995,28 @@ function applyReportedInputTransformations(
1965
1995
  }
1966
1996
  if (fresh.length === 0) return;
1967
1997
  output.inputTransformations = [...(output.inputTransformations ?? []), ...fresh];
1968
- rememberPrefixDroppedThinking(params, fresh, state);
1998
+ let repeated = 0;
1969
1999
  for (const transformation of fresh) {
1970
2000
  if (transformation.reason !== "prefix_binding_mismatch") continue;
2001
+ const dropKey = reportedDropKey(params, transformation.path);
2002
+ if (state && dropKey !== undefined) {
2003
+ if (state.reportedPrefixDroppedThinking.has(dropKey)) {
2004
+ repeated++;
2005
+ continue;
2006
+ }
2007
+ state.reportedPrefixDroppedThinking.add(dropKey);
2008
+ }
1971
2009
  logger.warn("anthropic: dropped thinking block after conversation prefix changed", {
1972
2010
  model: output.model,
1973
2011
  path: transformation.path,
1974
2012
  });
1975
2013
  }
2014
+ if (repeated > 0) {
2015
+ logger.debug("anthropic: API re-dropped previously reported thinking blocks", {
2016
+ model: output.model,
2017
+ count: repeated,
2018
+ });
2019
+ }
1976
2020
  }
1977
2021
 
1978
2022
  /**
@@ -2661,10 +2705,10 @@ const streamAnthropicOnce = (
2661
2705
  if (startMessage?.id) output.responseId = startMessage.id;
2662
2706
  applyReportedInputTransformations(
2663
2707
  output,
2664
- params,
2665
- providerSessionState,
2666
2708
  startMessage?.input_transformations,
2667
2709
  seenInputTransformations,
2710
+ params,
2711
+ providerSessionState,
2668
2712
  );
2669
2713
  const startUsage = startMessage?.usage;
2670
2714
  if (startUsage) {
@@ -2978,10 +3022,10 @@ const streamAnthropicOnce = (
2978
3022
  const delta = event.delta;
2979
3023
  applyReportedInputTransformations(
2980
3024
  output,
2981
- params,
2982
- providerSessionState,
2983
3025
  event.input_transformations,
2984
3026
  seenInputTransformations,
3027
+ params,
3028
+ providerSessionState,
2985
3029
  true,
2986
3030
  );
2987
3031
  const rawStopReason = delta?.stop_reason;
@@ -611,7 +611,7 @@ function classifyCursorStructuredError(structured: CursorStructuredError): Error
611
611
  if (code === 21) return new AIError.AbortError(fullMessage);
612
612
  const status = CURSOR_ERROR_STATUS[code];
613
613
  if (status !== undefined) return new AIError.ProviderHttpError(fullMessage, status, { code: name });
614
- if (retryable === true || CURSOR_RETRYABLE_ERROR_CODES.has(code)) {
614
+ if (retryable === true || (retryable === undefined && CURSOR_RETRYABLE_ERROR_CODES.has(code))) {
615
615
  return new AIError.ProviderHttpError(fullMessage, 503, { code: name });
616
616
  }
617
617
  return new AIError.ProviderHttpError(fullMessage, 400, { code: name });
@@ -1089,6 +1089,9 @@ function streamCursorWithWireMode(
1089
1089
  const h2Completion = Promise.withResolvers<void>();
1090
1090
  let h2Settled = false;
1091
1091
  let sawTurnEnded = false;
1092
+ // After the final step's `stepCompleted`, only a repeat checkpoint and the
1093
+ // `turnEnded` usage frame follow.
1094
+ let stepCompletedIsLatest = false;
1092
1095
  let endStreamError: Error | null = null;
1093
1096
  let progressVersion = 0;
1094
1097
  let latestCheckpointProgressVersion = -1;
@@ -1115,6 +1118,20 @@ function streamCursorWithWireMode(
1115
1118
  return;
1116
1119
  }
1117
1120
  if (!sawTurnEnded) {
1121
+ // A stream cut after the final step lost only usage and a repeat
1122
+ // checkpoint, so the turn is complete. The final step ends on answer
1123
+ // text; a step that ended on tool calls, or left one open, may be
1124
+ // followed by another step and is still treated as truncated.
1125
+ if (
1126
+ stepCompletedIsLatest &&
1127
+ output.content.at(-1)?.type === "text" &&
1128
+ openBlockState?.currentToolCall === null &&
1129
+ openBlockState.openToolCalls.size === 0
1130
+ ) {
1131
+ logger.debug("cursor stream ended after stepCompleted without turnEnded", { model: model.id });
1132
+ h2Completion.resolve();
1133
+ return;
1134
+ }
1118
1135
  h2Completion.reject(
1119
1136
  new AIError.ProviderResponseError("Cursor stream ended before turnEnded", {
1120
1137
  kind: "incomplete-stream",
@@ -1349,6 +1366,15 @@ function streamCursorWithWireMode(
1349
1366
  sawProgressOrSideEffect = true;
1350
1367
  progressVersion++;
1351
1368
  }
1369
+ if (interactionCase === "stepCompleted") {
1370
+ stepCompletedIsLatest = true;
1371
+ } else if (
1372
+ interactionCase !== "heartbeat" &&
1373
+ serverMessage.message.case !== "conversationCheckpointUpdate" &&
1374
+ serverMessage.message.case !== "kvServerMessage"
1375
+ ) {
1376
+ stepCompletedIsLatest = false;
1377
+ }
1352
1378
  if (serverMessage.message.case === "conversationCheckpointUpdate") {
1353
1379
  latestCheckpoint = serverMessage.message.value;
1354
1380
  latestCheckpointProgressVersion = progressVersion;
@@ -6,7 +6,9 @@ import type { OAuthController, OAuthCredentials } from "./types";
6
6
 
7
7
  const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
8
8
  const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
9
- const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key";
9
+ const CURSOR_REFRESH_URL = "https://api2.cursor.sh/oauth/token";
10
+ /** OAuth client the Cursor IDE renews its login session with. */
11
+ const CURSOR_CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
10
12
  const CURSOR_PROFILE_URL = "https://cursor.com/api/auth/me";
11
13
  const CURSOR_PROFILE_TIMEOUT_MS = 3_000;
12
14
 
@@ -118,14 +120,11 @@ export async function loginCursorHook(callbacks: OAuthController): Promise<OAuth
118
120
  return withCursorAccountEmail(credentials, callbacks.fetch ?? fetch, callbacks.signal);
119
121
  }
120
122
 
121
- export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<OAuthCredentials> {
123
+ export async function refreshCursorToken(refreshToken: string): Promise<OAuthCredentials> {
122
124
  const response = await fetch(CURSOR_REFRESH_URL, {
123
125
  method: "POST",
124
- headers: {
125
- Authorization: `Bearer ${apiKeyOrRefreshToken}`,
126
- "Content-Type": "application/json",
127
- },
128
- body: "{}",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ grant_type: "refresh_token", client_id: CURSOR_CLIENT_ID, refresh_token: refreshToken }),
129
128
  });
130
129
 
131
130
  if (!response.ok) {
@@ -136,17 +135,27 @@ export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<
136
135
  });
137
136
  }
138
137
 
139
- const data = (await response.json()) as {
140
- accessToken: string;
141
- refreshToken: string;
142
- };
143
-
144
- const expiresAt = getTokenExpiry(data.accessToken);
138
+ const data = (await response.json()) as { access_token?: string; refresh_token?: string; shouldLogout?: boolean };
139
+ // Cursor answers a session it will not renew with 200, an empty token and `shouldLogout`.
140
+ if (data.shouldLogout === true) {
141
+ throw new AIError.OAuthError("invalid_grant: Cursor ended this session; run /login cursor again", {
142
+ kind: "token-refresh",
143
+ provider: "cursor",
144
+ });
145
+ }
146
+ if (!data.access_token) {
147
+ throw new AIError.OAuthError("Cursor token refresh returned no access token", {
148
+ kind: "token-refresh",
149
+ provider: "cursor",
150
+ });
151
+ }
145
152
 
153
+ // Cursor's renewal answers only an access token; the IDE keeps its stored refresh token, so do the same
154
+ // unless a rotated one is returned.
146
155
  return {
147
- access: data.accessToken,
148
- refresh: data.refreshToken || apiKeyOrRefreshToken,
149
- expires: expiresAt,
156
+ access: data.access_token,
157
+ refresh: data.refresh_token || refreshToken,
158
+ expires: getTokenExpiry(data.access_token),
150
159
  };
151
160
  }
152
161
 
@@ -272,7 +272,7 @@ function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
272
272
  ? `${data.timePeriod.year}-${String(data.timePeriod.month).padStart(2, "0")}`
273
273
  : `${data.timePeriod.year}`;
274
274
  const window: UsageWindow = {
275
- id: "billing-period",
275
+ id: "monthly",
276
276
  label: periodLabel,
277
277
  };
278
278
 
@@ -0,0 +1,7 @@
1
+ /** Use the socket peer unless the server explicitly trusts its reverse proxy. */
2
+ export function resolvePeer(req: Request, socketAddress: string, trustProxyHeaders = false): string {
3
+ if (!trustProxyHeaders) return socketAddress;
4
+ const fwd = req.headers.get("x-forwarded-for");
5
+ if (fwd) return fwd.split(",")[0].trim();
6
+ return req.headers.get("x-real-ip") ?? socketAddress;
7
+ }