@oh-my-pi/pi-ai 18.8.3 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
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
+
5
27
  ## [18.8.3] - 2026-10-07
6
28
 
7
29
  ### Added
@@ -2307,67 +2329,4 @@
2307
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))
2308
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)).
2309
2331
 
2310
- ## [16.0.0] - 2026-06-15
2311
-
2312
- ### Breaking Changes
2313
-
2314
- - Renamed the public dialect entrypoint from `@oh-my-pi/pi-ai/grammar` to `@oh-my-pi/pi-ai/dialect`.
2315
- - Renamed grammar dialect identifiers from `ToolCallSyntax` to `Dialect`, renamed the `Grammar` interface to `DialectDefinition`, and renamed `Grammar.syntax` to `DialectDefinition.dialect`.
2316
- - Added `DialectDefinition.renderThinking` and `DialectDefinition.renderTranscript` so dialect implementations serialize complete native chat transcripts, not just tool call/result blocks.
2317
-
2318
- ### Added
2319
-
2320
- - Added `renderTranscript` method to dialect definitions for serializing complete native chat transcripts
2321
- - Added `renderThinking` method to dialect definitions for rendering thinking/reasoning blocks
2322
- - Added support for 11 dialect implementations: Anthropic, DeepSeek, Gemini, Gemma, GLM, Harmony, Hermes, Kimi, Pi-native, Qwen3, and XML
2323
- - Added `createInbandScanner` factory function to instantiate dialect-specific scanners
2324
- - Added `getDialectDefinition` function to retrieve dialect implementations by name
2325
- - Added `renderToolCatalog` and `renderInbandToolPrompt` functions for tool catalog rendering
2326
- - Added `renderToolInventory` function to generate human-readable per-tool documentation with examples
2327
- - Added `renderToolExamples` function to render tool usage examples in the model's native dialect
2328
- - Added `encodeInbandToolHistory` function to encode tool call history in dialect-specific format
2329
- - Added `wrapInbandToolStream` function to process streaming responses with in-band tool call parsing
2330
- - Added `ThinkingInbandScanner` for parsing thinking/reasoning blocks across dialects
2331
- - Added `OwnedStream` class for managing dialect-aware streaming with tool call events
2332
- - 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).
2333
-
2334
- ### Changed
2335
-
2336
- - Moved public dialect entrypoint from `@oh-my-pi/pi-ai/grammar` to `@oh-my-pi/pi-ai/dialect` in package exports
2337
- - Updated internal imports in `stream-markup-healing.ts` to use new dialect module path
2338
- - 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.
2339
-
2340
- ### Fixed
2341
-
2342
- - Fixed Gemini, Gemma, Kimi, and Pi in-band scanners to respect `parseThinking: false`, leaving private reasoning markers in visible text when parsing is disabled
2343
- - Fixed thinking-channel parsing for streaming Gemini, Gemma, Kimi, and Pi outputs so split or partial `<thinking>` blocks no longer leak into visible replies
2344
- - 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.
2345
-
2346
- ### Removed
2347
-
2348
- - Removed `src/grammar/factory.ts` (replaced by `src/dialect/factory.ts`)
2349
- - Removed `src/grammar/rendering.ts` (functionality moved to `src/dialect/rendering.ts`)
2350
- - Removed `src/grammar/xml.ts` (replaced by `src/dialect/xml.ts`)
2351
-
2352
- ## [15.13.3] - 2026-06-15
2353
-
2354
- ### Added
2355
-
2356
- - Added the `gemini` in-band tool-call syntax with Python-style `tool_code` blocks and `default_api` invocations
2357
- - Added the `gemma` token-delimited in-band tool-call syntax using `<|tool_call>` and `<|tool_response>` blocks
2358
- - Added `gemini` and `gemma` to owned stream tool-result token detection so their tool responses are recognized
2359
- - Fixed truncated Gemini and Gemma tool blocks from being emitted as plain text during streaming
2360
- - 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.
2361
-
2362
- ### Changed
2363
-
2364
- - 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.
2365
-
2366
- ### Fixed
2367
-
2368
- - Fixed duplicate tool call projections by deduplicating provider-native `toolCall` events against in-band `tool_code` calls and keeping only the first real channel
2369
- - Dropped nameless native `toolCall` events so they no longer appear as surfaced tool calls in owned-mode streams
2370
- - Fixed truncated Gemini and Gemma tool blocks from being emitted as plain text during streaming
2371
- - Fixed Gemini/Gemma in-band tool-call parsing around Python comments, raw/unicode string literals, and Gemma close-token text inside string values.
2372
-
2373
- 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).
@@ -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`
@@ -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
  /**
@@ -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.3",
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.3",
159
- "@oh-my-pi/pi-catalog": "18.8.3",
160
- "@oh-my-pi/pi-natives": "18.8.3",
161
- "@oh-my-pi/pi-utils": "18.8.3",
162
- "@oh-my-pi/pi-wire": "18.8.3"
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"
@@ -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
  }
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/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.
@@ -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
+ }