jeopi-ai 16.4.2 → 16.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.3] - 2026-07-22
6
+
7
+ ### Added
8
+
9
+ - Added diagnostic response headers to auth-gateway inference endpoints: `x-request-id`/`request-id` (correlates with gateway logs; surfaced by OpenAI/Anthropic SDKs) and LiteLLM-style `x-litellm-model-id`/`x-litellm-model-api-base` on every response, plus `x-litellm-response-cost`, `x-litellm-response-duration-ms`, and `openai-processing-ms` on non-streaming responses. Ported from oh-my-pi (upstream `3e5b7da6f`).
10
+ - Added Cursor OAuth and access-token usage reporting to `jeopi usage` via Cursor's account usage endpoint. Ported from oh-my-pi (upstream `965f5b0bb`).
11
+
12
+ ### Fixed
13
+
14
+ - OpenAI Responses server non-streaming envelopes always include the required `incomplete_details` field now, using `null` for completed responses instead of omitting the key. Ported from oh-my-pi (upstream `3188506e6`).
15
+ - Responses API tool results with genuinely empty content (e.g. an empty file read) no longer serialize as `"(see attached image)"` — the placeholder is now only emitted when the result actually carries an image block. Ported from oh-my-pi (upstream `31c9f4850`).
16
+ - Preserved Cloud Code Assist tool schemas when mixed-type unions carry branch-local validation descriptions: a differing `description` between merged union variants (or between the merged variant and the parent schema) now concatenates instead of aborting the collapse and leaving the schema on the incompatible `anyOf`/`oneOf` shape. Ported from oh-my-pi (upstream `3f52e26a7`).
17
+ - Fixed OAuth credential resolution returning "No API key found" when the only Pro-eligible OpenAI Codex account was usage-blocked and the sole unblocked account failed the model's Pro gate: resolution now runs a last-resort ladder that first yields a Pro-fitting account regardless of usage blocks (so callers get real usage-limit retry semantics instead of a missing key), then tries every account with the Pro filter dropped before reporting no credential. Ported from oh-my-pi (upstream `7cef4a769`, adapted to jeopi's Pro-only `enforceProRequirement` gate — jeopi has not adopted upstream's more general multi-tier `planRequirement` abstraction this fix originally targeted).
18
+ - Fixed empty provider responses (e.g. "Cloud Code Assist API returned an empty response") being classified as non-retryable: `ProviderResponseError` with kind `empty-body` now carries the transient flag, so session retry and configured model-fallback chains engage instead of hard-failing the turn. Ported from oh-my-pi (upstream `fabded89e`).
19
+ - Fixed the OAuth completion page copy to tell users they can close the tab manually when browsers such as Firefox ignore best-effort `window.close()` calls. Ported from oh-my-pi (upstream `5e781a9c7`).
20
+ - Fixed Cursor `max_mode` requests to send discovered max-mode metadata on both model payload fields (`modelDetails.maxMode` and the new `requestedModel`), so premium models that require max mode no longer silently run without it. Ported from oh-my-pi (upstream `358811115`).
21
+ - Fixed provider credential changes leaving persisted session-sticky OAuth credential mappings active, so existing sessions reselect accounts after login/logout instead of reusing stale `session:sticky:<provider>:<sessionId>` rows: `#resetProviderAssignments` (called on every credential add/remove/login) now also purges every persisted sticky cache row for that provider via a new `deleteCachePrefix` store method, implemented for both `SqliteAuthCredentialStore` (SQL `substr` prefix match) and `RemoteAuthCredentialStore` (in-memory prefix scan). Other providers' sticky rows are untouched. Ported from oh-my-pi (upstream `7029789e7`).
22
+ - Fixed Codex saved-reset redemption to include the selected account in the consume request body, so `/usage reset` applies to the chosen OpenAI account in multi-account setups instead of an ambiguous default. Ported from oh-my-pi (upstream `1d9889810`).
23
+ - Fixed OpenAI Responses `content_filter` terminal events being auto-retried as provider finish errors; `kind: "content-blocked"` provider errors now classify under a dedicated `Flag.ContentBlocked` (kept out of the retriable set) instead of reusing `Flag.ProviderFinishError` (which is retriable), so a safety-filter block stays a hard failure without a same-model retry loop. Ported from oh-my-pi (upstream `c95a2b993` by @belchetz).
24
+ - Fixed OpenAI Chat Completions request parsing to accept assistant tool-call replay messages with `content: null` as absent content, instead of rejecting the whole request when a client (or the gateway's own prior response) round-trips a `null` content field alongside `tool_calls`. Ported from oh-my-pi (upstream `38af95646`).
25
+ - Fixed auth-broker config discovery to accept nested `auth.broker.url` and `auth.broker.token` YAML keys from either `config.yml` or `config.yaml`, while preserving the legacy flat dotted form (nested values win). ([#4734](https://github.com/can1357/oh-my-pi/issues/4734), upstream `b190a3c15` and `5c16dcb15`).
26
+
5
27
  ## [16.2.28] - 2026-07-08
6
28
 
7
29
  ### Fixed
@@ -19,7 +19,8 @@ export declare function getAuthBrokerTokenFilePath(): string;
19
19
  * Resolve broker connection configuration using the same precedence as the TUI:
20
20
  *
21
21
  * 1. `JEOPI_AUTH_BROKER_URL` / `JEOPI_AUTH_BROKER_TOKEN` env vars.
22
- * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
22
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or
23
+ * `<agentDir>/config.yaml`.
23
24
  * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
24
25
  *
25
26
  * Returns `null` when no broker URL is configured — callers should fall back to
@@ -73,6 +73,8 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
73
73
  deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void>;
74
74
  getCache(key: string): string | null;
75
75
  setCache(key: string, value: string, expiresAtSec: number): void;
76
+ /** Drop all cache rows whose keys start with the supplied prefix. */
77
+ deleteCachePrefix(prefix: string): void;
76
78
  cleanExpiredCache(): void;
77
79
  /**
78
80
  * Store-level hook consumed by `AuthStorage` — routes refresh through the
@@ -1,4 +1,21 @@
1
- export declare function json(status: number, body: unknown): Response;
1
+ import type { Api, AssistantMessage, Model } from "../types";
2
+ export declare function json(status: number, body: unknown, headers?: Record<string, string>): Response;
3
+ /**
4
+ * Diagnostic response headers for translated inference requests, mirroring the
5
+ * names existing gateway-aware clients already parse: `x-request-id` /
6
+ * `request-id` (surfaced as `_request_id` by the OpenAI and Anthropic SDKs,
7
+ * matches the gateway log line), LiteLLM's model-resolution and cost headers,
8
+ * and OpenAI's `openai-processing-ms`. Model/request-id headers are always
9
+ * present; `message` — the final assistant message, available only on
10
+ * non-streaming responses — adds the computed cost, and `startedAt` the wall
11
+ * time. Streaming responses send headers before usage exists, so they carry
12
+ * only the identity headers.
13
+ */
14
+ export declare function gatewayResponseHeaders(model: Model<Api>, info: {
15
+ requestId: string;
16
+ message?: AssistantMessage;
17
+ startedAt?: number;
18
+ }): Record<string, string>;
2
19
  export declare function resolvePeer(req: Request): string;
3
20
  /**
4
21
  * Constant-time byte comparison. Falls back to a manual XOR accumulator if
@@ -226,6 +226,8 @@ export interface AuthCredentialStore {
226
226
  includeExpired?: boolean;
227
227
  }): string | null;
228
228
  setCache(key: string, value: string, expiresAtSec: number): void;
229
+ /** Drop all cache rows whose keys start with the supplied prefix. */
230
+ deleteCachePrefix?(prefix: string): void;
229
231
  cleanExpiredCache(): void;
230
232
  /**
231
233
  * Append usage-limit snapshots for trend history. Optional: stores without
@@ -992,6 +994,8 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
992
994
  includeExpired?: boolean;
993
995
  }): string | null;
994
996
  setCache(key: string, value: string, expiresAtSec: number): void;
997
+ /** Drop all cache rows whose keys start with the supplied prefix. */
998
+ deleteCachePrefix(prefix: string): void;
995
999
  cleanExpiredCache(): void;
996
1000
  recordUsageSnapshots(entries: UsageHistoryEntry[]): void;
997
1001
  listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[];
@@ -8,6 +8,7 @@ export declare const Flag: {
8
8
  readonly StaleResponsesItem: 1048576;
9
9
  readonly MalformedFunctionCall: 2097152;
10
10
  readonly ProviderFinishError: 4194304;
11
+ readonly ContentBlocked: 32768;
11
12
  readonly ContextOverflow: 8388608;
12
13
  readonly AuthFailed: 16777216;
13
14
  readonly SilentAbort: 33554432;
@@ -29,6 +29,7 @@ export * from "./stream";
29
29
  export * from "./types";
30
30
  export * from "./usage";
31
31
  export * from "./usage/claude";
32
+ export * from "./usage/cursor";
32
33
  export * from "./usage/gemini";
33
34
  export * from "./usage/github-copilot";
34
35
  export * from "./usage/google-antigravity";
@@ -232,7 +232,7 @@ export declare const assistantMessageSchema: import("arktype/internal/variants/o
232
232
  refusal: string;
233
233
  } | {
234
234
  type: string;
235
- })[] | undefined;
235
+ })[] | null | undefined;
236
236
  tool_calls?: {
237
237
  id: string;
238
238
  type?: "function" | undefined;
@@ -404,7 +404,7 @@ export declare const messageSchema: import("arktype/internal/variants/object.ts"
404
404
  refusal: string;
405
405
  } | {
406
406
  type: string;
407
- })[] | undefined;
407
+ })[] | null | undefined;
408
408
  tool_calls?: {
409
409
  id: string;
410
410
  type?: "function" | undefined;
@@ -581,7 +581,7 @@ export declare const openaiChatRequestSchema: import("arktype/internal/variants/
581
581
  refusal: string;
582
582
  } | {
583
583
  type: string;
584
- })[] | undefined;
584
+ })[] | null | undefined;
585
585
  tool_calls?: {
586
586
  id: string;
587
587
  type?: "function" | undefined;
@@ -0,0 +1,3 @@
1
+ import type { UsageProvider, UsageReport } from "../usage";
2
+ export declare function parseCursorUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
3
+ export declare const cursorUsageProvider: UsageProvider;
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * GET /wham/rate-limit-reset-credits → list redeemable credits
10
10
  * POST /wham/rate-limit-reset-credits/consume → spend one credit
11
- * body: { credit_id, redeem_request_id }
11
+ * body: { credit_id, redeem_request_id, account_id? }
12
12
  *
13
13
  * `redeem_request_id` is a client-generated idempotency key (UUID). The consume
14
14
  * response carries a `code`: `"reset"` on success, otherwise a business reason
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-ai",
4
- "version": "16.4.2",
4
+ "version": "16.4.3",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "jeopi-catalog": "16.4.2",
42
- "jeopi-utils": "16.4.2",
43
- "jeopi-wire": "16.4.2",
41
+ "jeopi-catalog": "16.4.3",
42
+ "jeopi-utils": "16.4.3",
43
+ "jeopi-wire": "16.4.3",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Broker-aware auth-storage discovery used by both the coding-agent runtime and
3
- * the catalog model generator. Keeps the precedence logic (env → config.yml →
3
+ * the catalog model generator. Keeps the precedence logic (env → config YAML →
4
4
  * token file → local SQLite) in one place so build-time tooling sees the same
5
5
  * credentials as the TUI.
6
6
  */
@@ -71,22 +71,42 @@ interface ConfigSnapshot {
71
71
  token?: string;
72
72
  }
73
73
 
74
+ /**
75
+ * Resolve a dotted config key against a parsed YAML record, accepting nested
76
+ * keys and the legacy literal-dot form. Nested values take precedence.
77
+ */
78
+ function readDottedString(record: Record<string, unknown>, dottedKey: string): string | undefined {
79
+ let current: unknown = record;
80
+ for (const segment of dottedKey.split(".")) {
81
+ if (current === null || typeof current !== "object" || Array.isArray(current)) {
82
+ current = undefined;
83
+ break;
84
+ }
85
+ current = (current as Record<string, unknown>)[segment];
86
+ }
87
+ if (typeof current === "string") return current;
88
+ const flat = record[dottedKey];
89
+ return typeof flat === "string" ? flat : undefined;
90
+ }
91
+
74
92
  async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
75
- const configPath = path.join(agentDir, "config.yml");
76
- try {
77
- const raw = await Bun.file(configPath).text();
78
- const parsed = YAML.parse(raw);
79
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
80
- const record = parsed as Record<string, unknown>;
81
- const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
82
- const token =
83
- typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
84
- return { url, token };
85
- } catch (err) {
86
- if (isEnoent(err)) return {};
87
- logger.warn("auth-broker config.yml unreadable", { error: String(err) });
88
- return {};
93
+ for (const filename of ["config.yml", "config.yaml"]) {
94
+ const configPath = path.join(agentDir, filename);
95
+ try {
96
+ const raw = await Bun.file(configPath).text();
97
+ const parsed = YAML.parse(raw);
98
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
99
+ const record = parsed as Record<string, unknown>;
100
+ const url = readDottedString(record, "auth.broker.url");
101
+ const token = readDottedString(record, "auth.broker.token");
102
+ return { url, token };
103
+ } catch (err) {
104
+ if (isEnoent(err)) continue;
105
+ logger.warn("auth-broker config unreadable", { filename, error: String(err) });
106
+ return {};
107
+ }
89
108
  }
109
+ return {};
90
110
  }
91
111
 
92
112
  function resolveSnapshotTtlMs(): number {
@@ -104,7 +124,8 @@ function resolveSnapshotTtlMs(): number {
104
124
  * Resolve broker connection configuration using the same precedence as the TUI:
105
125
  *
106
126
  * 1. `JEOPI_AUTH_BROKER_URL` / `JEOPI_AUTH_BROKER_TOKEN` env vars.
107
- * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
127
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or
128
+ * `<agentDir>/config.yaml`.
108
129
  * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
109
130
  *
110
131
  * Returns `null` when no broker URL is configured — callers should fall back to
@@ -468,6 +468,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
468
468
  this.#cache.set(key, { value, expiresAtSec });
469
469
  }
470
470
 
471
+ /** Drop all cache rows whose keys start with the supplied prefix. */
472
+ deleteCachePrefix(prefix: string): void {
473
+ for (const key of this.#cache.keys()) {
474
+ if (key.startsWith(prefix)) this.#cache.delete(key);
475
+ }
476
+ }
477
+
471
478
  cleanExpiredCache(): void {
472
479
  const nowSec = Math.floor(Date.now() / 1000);
473
480
  for (const [key, entry] of this.#cache) {
@@ -5,19 +5,50 @@
5
5
  * and peer-resolution logic.
6
6
  */
7
7
  import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
8
+ import type { Api, AssistantMessage, Model } from "../types";
8
9
 
9
10
  const JSON_HEADERS = {
10
11
  "Content-Type": "application/json",
11
12
  "X-Content-Type-Options": "nosniff",
12
13
  } as const;
13
14
 
14
- export function json(status: number, body: unknown): Response {
15
+ export function json(status: number, body: unknown, headers?: Record<string, string>): Response {
15
16
  return new Response(JSON.stringify(body) ?? "null", {
16
17
  status,
17
- headers: JSON_HEADERS,
18
+ headers: headers ? { ...JSON_HEADERS, ...headers } : JSON_HEADERS,
18
19
  });
19
20
  }
20
21
 
22
+ /**
23
+ * Diagnostic response headers for translated inference requests, mirroring the
24
+ * names existing gateway-aware clients already parse: `x-request-id` /
25
+ * `request-id` (surfaced as `_request_id` by the OpenAI and Anthropic SDKs,
26
+ * matches the gateway log line), LiteLLM's model-resolution and cost headers,
27
+ * and OpenAI's `openai-processing-ms`. Model/request-id headers are always
28
+ * present; `message` — the final assistant message, available only on
29
+ * non-streaming responses — adds the computed cost, and `startedAt` the wall
30
+ * time. Streaming responses send headers before usage exists, so they carry
31
+ * only the identity headers.
32
+ */
33
+ export function gatewayResponseHeaders(
34
+ model: Model<Api>,
35
+ info: { requestId: string; message?: AssistantMessage; startedAt?: number },
36
+ ): Record<string, string> {
37
+ const headers: Record<string, string> = {
38
+ "x-request-id": info.requestId,
39
+ "request-id": info.requestId,
40
+ "x-litellm-model-id": model.id,
41
+ };
42
+ if (model.baseUrl) headers["x-litellm-model-api-base"] = model.baseUrl;
43
+ if (info.message) headers["x-litellm-response-cost"] = info.message.usage.cost.total.toString();
44
+ if (info.startedAt !== undefined) {
45
+ const elapsed = (performance.now() - info.startedAt).toFixed(0);
46
+ headers["x-litellm-response-duration-ms"] = elapsed;
47
+ headers["openai-processing-ms"] = elapsed;
48
+ }
49
+ return headers;
50
+ }
51
+
21
52
  export function resolvePeer(req: Request): string {
22
53
  const fwd = req.headers.get("x-forwarded-for");
23
54
  if (fwd) return fwd.split(",")[0].trim();
@@ -165,6 +196,8 @@ const CORS_HEADERS: Record<string, string> = {
165
196
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
166
197
  "Access-Control-Allow-Headers":
167
198
  "authorization, content-type, anthropic-version, anthropic-beta, openai-organization, openai-project, x-stainless-*, x-api-key",
199
+ "Access-Control-Expose-Headers":
200
+ "x-request-id, request-id, x-litellm-model-id, x-litellm-model-api-base, x-litellm-response-cost, x-litellm-response-duration-ms, openai-processing-ms",
168
201
  "Access-Control-Max-Age": "86400",
169
202
  };
170
203
 
@@ -32,7 +32,15 @@ import { completeSimple, streamSimple } from "../stream";
32
32
  import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
33
33
  import { deterministicUuid } from "../utils/deterministic-id";
34
34
  import { parseBind } from "../utils/parse-bind";
35
- import { captureRequestHeaders, corsHeaders, isAuthorized, json, resolvePeer, withCors } from "./http";
35
+ import {
36
+ captureRequestHeaders,
37
+ corsHeaders,
38
+ gatewayResponseHeaders,
39
+ isAuthorized,
40
+ json,
41
+ resolvePeer,
42
+ withCors,
43
+ } from "./http";
36
44
  import type {
37
45
  AuthGatewayServerHandle,
38
46
  AuthGatewayServerOptions,
@@ -333,6 +341,8 @@ async function handleFormatEndpoint(
333
341
  req: Request,
334
342
  peer: string,
335
343
  ): Promise<Response> {
344
+ const startedAt = performance.now();
345
+ const requestId = crypto.randomUUID();
336
346
  const controller = mirrorRequestAbort(req);
337
347
  if (controller.signal.aborted) return clientClosedResponse(route);
338
348
 
@@ -429,6 +439,7 @@ async function handleFormatEndpoint(
429
439
  );
430
440
 
431
441
  logger.info("auth-gateway request", {
442
+ requestId,
432
443
  format: route.label,
433
444
  model: parsed.modelId,
434
445
  resolvedProvider: model.provider,
@@ -457,7 +468,11 @@ async function handleFormatEndpoint(
457
468
  const classified = classifyGatewayError(errorMessage);
458
469
  return route.module.formatError(classified.status, classified.type, errorMessage);
459
470
  }
460
- return json(200, route.module.encodeResponse(message, parsed.modelId));
471
+ return json(
472
+ 200,
473
+ route.module.encodeResponse(message, parsed.modelId),
474
+ gatewayResponseHeaders(model, { requestId, message, startedAt }),
475
+ );
461
476
  } catch (error) {
462
477
  if (controller.signal.aborted) return clientClosedResponse(route);
463
478
  const classified = classifyGatewayError(error);
@@ -492,6 +507,7 @@ async function handleFormatEndpoint(
492
507
  return new Response(sseStream, {
493
508
  status: 200,
494
509
  headers: {
510
+ ...gatewayResponseHeaders(model, { requestId }),
495
511
  "Content-Type": "text/event-stream; charset=utf-8",
496
512
  "Cache-Control": "no-cache",
497
513
  Connection: "keep-alive",
@@ -518,6 +534,8 @@ async function handleFormatEndpoint(
518
534
  * path.
519
535
  */
520
536
  async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
537
+ const startedAt = performance.now();
538
+ const requestId = crypto.randomUUID();
521
539
  const controller = mirrorRequestAbort(req);
522
540
  const aborted = (): Response => piNative.formatError(499, "request_aborted", "client closed request");
523
541
  if (controller.signal.aborted) return aborted();
@@ -604,6 +622,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
604
622
  streamOpts.sessionId ??= sessionId;
605
623
 
606
624
  logger.info("auth-gateway request", {
625
+ requestId,
607
626
  format: "pi-native",
608
627
  model: parsed.modelId,
609
628
  resolvedProvider: model.provider,
@@ -632,7 +651,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
632
651
  const classified = classifyGatewayError(errorMessage);
633
652
  return piNative.formatError(classified.status, classified.type, errorMessage);
634
653
  }
635
- return json(200, { message });
654
+ return json(200, { message }, gatewayResponseHeaders(model, { requestId, message, startedAt }));
636
655
  } catch (error) {
637
656
  if (controller.signal.aborted) return aborted();
638
657
  const classified = classifyGatewayError(error);
@@ -663,6 +682,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
663
682
  return new Response(sseStream, {
664
683
  status: 200,
665
684
  headers: {
685
+ ...gatewayResponseHeaders(model, { requestId }),
666
686
  "Content-Type": "text/event-stream; charset=utf-8",
667
687
  "Cache-Control": "no-cache",
668
688
  Connection: "keep-alive",
@@ -36,6 +36,7 @@ import type {
36
36
  } from "./usage";
37
37
  import { resolveUsedFraction } from "./usage";
38
38
  import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
39
+ import { cursorUsageProvider } from "./usage/cursor";
39
40
  import { googleGeminiCliUsageProvider } from "./usage/gemini";
40
41
  import { githubCopilotUsageProvider } from "./usage/github-copilot";
41
42
  import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity";
@@ -52,6 +53,7 @@ import { opencodeGoUsageProvider } from "./usage/opencode-go";
52
53
  import { zaiUsageProvider } from "./usage/zai";
53
54
 
54
55
  const USAGE_RANKING_METRIC_EPSILON = 1e-9;
56
+ const SESSION_STICKY_CACHE_PREFIX = "session:sticky:";
55
57
 
56
58
  // ─────────────────────────────────────────────────────────────────────────────
57
59
  // Credential Types
@@ -298,6 +300,8 @@ export interface AuthCredentialStore {
298
300
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
299
301
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
300
302
  setCache(key: string, value: string, expiresAtSec: number): void;
303
+ /** Drop all cache rows whose keys start with the supplied prefix. */
304
+ deleteCachePrefix?(prefix: string): void;
301
305
  cleanExpiredCache(): void;
302
306
  /**
303
307
  * Append usage-limit snapshots for trend history. Optional: stores without
@@ -504,6 +508,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
504
508
  zaiUsageProvider,
505
509
  opencodeGoUsageProvider,
506
510
  githubCopilotUsageProvider,
511
+ cursorUsageProvider,
507
512
  ];
508
513
 
509
514
  const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
@@ -1357,7 +1362,7 @@ export class AuthStorage {
1357
1362
  try {
1358
1363
  const credentialId = this.#getStoredCredentials(provider)[index]?.id;
1359
1364
  if (credentialId !== undefined) {
1360
- const cacheKey = `session:sticky:${provider}:${sessionId}`;
1365
+ const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
1361
1366
  const cacheValue = JSON.stringify({ type, index, credentialId });
1362
1367
  // Expires in 30 days
1363
1368
  const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
@@ -1379,7 +1384,7 @@ export class AuthStorage {
1379
1384
  return sessionMap.get(sessionId);
1380
1385
  }
1381
1386
  try {
1382
- const cacheKey = `session:sticky:${provider}:${sessionId}`;
1387
+ const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
1383
1388
  const raw = this.#store.getCache(cacheKey);
1384
1389
  if (raw) {
1385
1390
  const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number };
@@ -1423,7 +1428,7 @@ export class AuthStorage {
1423
1428
  }
1424
1429
  }
1425
1430
  try {
1426
- const cacheKey = `session:sticky:${provider}:${sessionId}`;
1431
+ const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
1427
1432
  this.#store.setCache(cacheKey, "", 0);
1428
1433
  } catch (err) {
1429
1434
  logger.debug("Failed to clear session sticky credential from persistent store cache", { err });
@@ -1464,6 +1469,14 @@ export class AuthStorage {
1464
1469
  return fallback;
1465
1470
  }
1466
1471
 
1472
+ #clearProviderSessionCredentialCache(provider: string): void {
1473
+ try {
1474
+ this.#store.deleteCachePrefix?.(`${SESSION_STICKY_CACHE_PREFIX}${provider}:`);
1475
+ } catch (err) {
1476
+ logger.debug("Failed to clear provider session sticky credentials from persistent store cache", { err });
1477
+ }
1478
+ }
1479
+
1467
1480
  /**
1468
1481
  * Clears round-robin and session assignment state for a provider.
1469
1482
  * Called when credentials are added/removed to prevent stale index references.
@@ -1475,6 +1488,7 @@ export class AuthStorage {
1475
1488
  }
1476
1489
  }
1477
1490
  this.#sessionLastCredential.delete(provider);
1491
+ this.#clearProviderSessionCredentialCache(provider);
1478
1492
  for (const key of this.#credentialBackoff.keys()) {
1479
1493
  if (key.startsWith(`${provider}:`)) {
1480
1494
  this.#credentialBackoff.delete(key);
@@ -3222,8 +3236,17 @@ export class AuthStorage {
3222
3236
 
3223
3237
  /**
3224
3238
  * Resolves an OAuth credential, trying credentials in priority order.
3225
- * Skips blocked credentials and checks usage limits for providers with usage data.
3226
- * Falls back to earliest-unblocking credential if all are blocked.
3239
+ *
3240
+ * Resolution ladder — a request in hand always beats "no API key":
3241
+ * 1. strict: unblocked credentials only, usage limits respected, Pro
3242
+ * filter enforced (when any account is confirmed Pro-eligible);
3243
+ * 2. Pro-fitting last resort: same Pro filter, but blocked/exhausted
3244
+ * accounts are allowed (blocked candidates rank earliest-unblocking
3245
+ * first) so the caller gets real usage-limit semantics from the wire
3246
+ * instead of a missing key;
3247
+ * 3. unfiltered last resort: the Pro filter matched nothing usable —
3248
+ * skip it and try every account once; the server is the final arbiter
3249
+ * of model access.
3227
3250
  *
3228
3251
  * Returns both the API key bytes for outbound requests AND the refreshed
3229
3252
  * {@link OAuthCredential} so callers needing identity metadata (account id,
@@ -3360,40 +3383,33 @@ export class AuthStorage {
3360
3383
  const enforceProRequirement =
3361
3384
  requiresProModel && candidates.some(candidate => hasOpenAICodexProPlan(candidate.usage));
3362
3385
 
3363
- const fallback = candidates[0];
3386
+ const passes: Array<{ allowBlocked: boolean; enforceProRequirement: boolean }> = [
3387
+ { allowBlocked: false, enforceProRequirement },
3388
+ { allowBlocked: true, enforceProRequirement },
3389
+ ];
3390
+ if (enforceProRequirement) passes.push({ allowBlocked: true, enforceProRequirement: false });
3364
3391
 
3365
- for (const candidate of candidates) {
3366
- const resolved = await this.#tryOAuthCredential(
3367
- provider,
3368
- candidate.selection,
3369
- providerKey,
3370
- sessionId,
3371
- options,
3372
- {
3373
- checkUsage,
3374
- allowBlocked: false,
3375
- prefetchedUsage: candidate.usage,
3376
- usagePrechecked: candidate.usageChecked,
3377
- enforceProRequirement,
3378
- strategy,
3379
- rankingContext,
3380
- blockScope,
3381
- },
3382
- );
3383
- if (resolved) return resolved;
3384
- }
3385
-
3386
- if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index, blockScope)) {
3387
- return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
3388
- checkUsage,
3389
- allowBlocked: true,
3390
- prefetchedUsage: fallback.usage,
3391
- usagePrechecked: fallback.usageChecked,
3392
- enforceProRequirement,
3393
- strategy,
3394
- rankingContext,
3395
- blockScope,
3396
- });
3392
+ for (const pass of passes) {
3393
+ for (const candidate of candidates) {
3394
+ const resolved = await this.#tryOAuthCredential(
3395
+ provider,
3396
+ candidate.selection,
3397
+ providerKey,
3398
+ sessionId,
3399
+ options,
3400
+ {
3401
+ checkUsage,
3402
+ allowBlocked: pass.allowBlocked,
3403
+ prefetchedUsage: candidate.usage,
3404
+ usagePrechecked: candidate.usageChecked,
3405
+ enforceProRequirement: pass.enforceProRequirement,
3406
+ strategy,
3407
+ rankingContext,
3408
+ blockScope,
3409
+ },
3410
+ );
3411
+ if (resolved) return resolved;
3412
+ }
3397
3413
  }
3398
3414
 
3399
3415
  return undefined;
@@ -4755,6 +4771,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4755
4771
  #getCacheStmt: Statement;
4756
4772
  #getCacheIncludingExpiredStmt: Statement;
4757
4773
  #upsertCacheStmt: Statement;
4774
+ #deleteCachePrefixStmt: Statement;
4758
4775
  #deleteExpiredCacheStmt: Statement;
4759
4776
  #insertUsageHistoryStmt: Statement;
4760
4777
  #insertUsageCostStmt: Statement;
@@ -4800,6 +4817,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4800
4817
  this.#upsertCacheStmt = this.#db.prepare(
4801
4818
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
4802
4819
  );
4820
+ this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
4803
4821
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
4804
4822
  this.#insertUsageHistoryStmt = this.#db.prepare(
4805
4823
  "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -5364,6 +5382,15 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5364
5382
  }
5365
5383
  }
5366
5384
 
5385
+ /** Drop all cache rows whose keys start with the supplied prefix. */
5386
+ deleteCachePrefix(prefix: string): void {
5387
+ try {
5388
+ this.#deleteCachePrefixStmt.run(prefix.length, prefix);
5389
+ } catch {
5390
+ // Ignore cache delete failures
5391
+ }
5392
+ }
5393
+
5367
5394
  cleanExpiredCache(): void {
5368
5395
  try {
5369
5396
  this.#deleteExpiredCacheStmt.run();
@@ -5564,6 +5591,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5564
5591
  this.#getCacheStmt.finalize();
5565
5592
  this.#getCacheIncludingExpiredStmt.finalize();
5566
5593
  this.#upsertCacheStmt.finalize();
5594
+ this.#deleteCachePrefixStmt.finalize();
5567
5595
  this.#deleteExpiredCacheStmt.finalize();
5568
5596
  this.#insertUsageHistoryStmt.finalize();
5569
5597
  this.#lastUsageHistoryStmt.finalize();
@@ -17,6 +17,7 @@ export const Flag = {
17
17
  StaleResponsesItem: 0x0010_0000,
18
18
  MalformedFunctionCall: 0x0020_0000,
19
19
  ProviderFinishError: 0x0040_0000,
20
+ ContentBlocked: 0x0000_8000,
20
21
  ContextOverflow: 0x0080_0000,
21
22
  AuthFailed: 0x0100_0000,
22
23
  SilentAbort: 0x0200_0000,
@@ -40,6 +41,7 @@ const KIND_MASK =
40
41
  Flag.StaleResponsesItem |
41
42
  Flag.MalformedFunctionCall |
42
43
  Flag.ProviderFinishError |
44
+ Flag.ContentBlocked |
43
45
  Flag.ContextOverflow |
44
46
  Flag.AuthFailed |
45
47
  Flag.SilentAbort |
@@ -92,6 +94,7 @@ const AUTH_FAILURE_PATTERN =
92
94
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
93
95
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
94
96
  const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
97
+ const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
95
98
  const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
96
99
  const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
97
100
  /**
@@ -167,6 +170,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
167
170
  [Flag.StaleResponsesItem, "stale-responses-item"],
168
171
  [Flag.MalformedFunctionCall, "malformed-function-call"],
169
172
  [Flag.ProviderFinishError, "provider-finish-error"],
173
+ [Flag.ContentBlocked, "content-blocked"],
170
174
  [Flag.ContextOverflow, "context-overflow"],
171
175
  [Flag.AuthFailed, "auth-failed"],
172
176
  [Flag.SilentAbort, "silent-abort"],
@@ -285,6 +289,10 @@ function isProviderFinishErrorText(text: string): boolean {
285
289
  return PROVIDER_FINISH_ERROR_PATTERN.test(text);
286
290
  }
287
291
 
292
+ function isContentBlockedText(text: string): boolean {
293
+ return CONTENT_FILTER_PATTERN.test(text);
294
+ }
295
+
288
296
  function matchesOverflowText(text: string): boolean {
289
297
  return OVERFLOW_PATTERNS.some(p => p.test(text)) || OVERFLOW_NO_BODY_PATTERN.test(text);
290
298
  }
@@ -295,6 +303,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
295
303
  if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
296
304
  if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
297
305
  if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
306
+ if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
298
307
  if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
299
308
 
300
309
  const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined);
@@ -36,13 +36,14 @@ export class ProviderResponseError extends Error {
36
36
  this.name = "ProviderResponseError";
37
37
  this.provider = options.provider;
38
38
  this.kind = options.kind ?? "output";
39
- // A safety filter block is a terminal provider finish, not a transient fault.
40
- if (this.kind === "content-blocked") attach(this, create(Flag.ProviderFinishError));
39
+ // A safety filter block is terminal and intentionally non-retryable.
40
+ if (this.kind === "content-blocked") attach(this, create(Flag.ContentBlocked));
41
41
  // An incomplete stream (connection dropped / truncated before any terminal
42
- // event) never produced a finish reason — the request didn't complete, so it
43
- // is safe to retry. The retry layer's replay-unsafe guard still blocks a
44
- // retry when partial tool output was already emitted.
45
- else if (this.kind === "incomplete-stream") attach(this, create(Flag.Transient));
42
+ // event) or an empty body never produced any content — the request didn't
43
+ // complete, so it is safe to retry and eligible for model fallback. The
44
+ // retry layer's replay-unsafe guard still blocks a retry when partial tool
45
+ // output was already emitted.
46
+ else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient));
46
47
  }
47
48
  }
48
49
 
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export * from "./stream";
29
29
  export * from "./types";
30
30
  export * from "./usage";
31
31
  export * from "./usage/claude";
32
+ export * from "./usage/cursor";
32
33
  export * from "./usage/gemini";
33
34
  export * from "./usage/github-copilot";
34
35
  export * from "./usage/google-antigravity";
@@ -76,6 +76,7 @@ import {
76
76
  RequestContextResultSchema,
77
77
  RequestContextSchema,
78
78
  RequestContextSuccessSchema,
79
+ RequestedModelSchema,
79
80
  ResumeActionSchema,
80
81
  SelectedContextSchema,
81
82
  SelectedImageSchema,
@@ -2642,16 +2643,24 @@ function buildGrpcRequest(
2642
2643
  turns,
2643
2644
  });
2644
2645
 
2646
+ const wireModelId = model.requestModelId ?? model.id;
2647
+ const cursorMaxMode = model.cursorMaxMode === true;
2645
2648
  const modelDetails = create(ModelDetailsSchema, {
2646
- modelId: model.id,
2649
+ modelId: wireModelId,
2647
2650
  displayModelId: model.id,
2648
2651
  displayName: model.name,
2652
+ ...(cursorMaxMode ? { maxMode: true } : undefined),
2653
+ });
2654
+ const requestedModel = create(RequestedModelSchema, {
2655
+ modelId: wireModelId,
2656
+ maxMode: cursorMaxMode,
2649
2657
  });
2650
2658
 
2651
2659
  const runRequest = create(AgentRunRequestSchema, {
2652
2660
  conversationState,
2653
2661
  action,
2654
2662
  modelDetails,
2663
+ requestedModel,
2655
2664
  conversationId: state.conversationId,
2656
2665
  });
2657
2666
 
@@ -114,6 +114,7 @@ export const toolChoiceSchema = type("'auto' | 'none' | 'required'")
114
114
  // ─── Messages ───────────────────────────────────────────────────────────────
115
115
 
116
116
  const baseContent = type("string").or(userContentPartSchema.array());
117
+ const assistantContent = baseContent.or("null");
117
118
 
118
119
  export const systemMessageSchema = type({
119
120
  role: "'system'",
@@ -132,7 +133,7 @@ export const userMessageSchema = type({
132
133
 
133
134
  export const assistantMessageSchema = type({
134
135
  role: "'assistant'",
135
- "content?": baseContent,
136
+ "content?": assistantContent,
136
137
  "tool_calls?": toolCallSchema.array(),
137
138
  // DeepSeek-style reasoning channel. The gateway emits it on the way out
138
139
  // (encodeResponse/encodeStream); accept it back so thinking-mode
@@ -281,7 +281,7 @@ export async function transformRequestBody(
281
281
 
282
282
  body.text = {
283
283
  ...body.text,
284
- verbosity: options.textVerbosity || "high",
284
+ verbosity: options.textVerbosity || "medium",
285
285
  };
286
286
 
287
287
  const include = Array.isArray(options.include) ? [...options.include] : [];
@@ -561,6 +561,10 @@ function responseStatusForStopReason(message: AssistantMessage): ResponseStatus
561
561
  return "completed";
562
562
  }
563
563
 
564
+ function incompleteDetailsForStatus(status: ResponseStatus): { reason: "max_output_tokens" } | null {
565
+ return status === "incomplete" ? { reason: "max_output_tokens" } : null;
566
+ }
567
+
564
568
  function buildReasoningItem(part: ThinkingContent): ReasoningOutputItem {
565
569
  const baseId = part.itemId ?? makeReasoningId();
566
570
  if (part.thinkingSignature) {
@@ -711,7 +715,7 @@ function buildResponseEnvelope(
711
715
  model: requestedModelId,
712
716
  output: items,
713
717
  usage,
714
- ...(status === "incomplete" ? { incomplete_details: { reason: "max_output_tokens" } } : {}),
718
+ incomplete_details: incompleteDetailsForStatus(status),
715
719
  ...(status === "failed" ? { error: { message: message.errorMessage ?? "response failed" } } : {}),
716
720
  };
717
721
  }
@@ -805,6 +809,7 @@ export function encodeStream(
805
809
  model: requestedModelId,
806
810
  output,
807
811
  usage: null,
812
+ incomplete_details: incompleteDetailsForStatus(status),
808
813
  });
809
814
 
810
815
  const openMessage = (signature?: MessageSignature): OpenMessage => {
@@ -1235,7 +1240,7 @@ export function encodeStream(
1235
1240
  model: requestedModelId,
1236
1241
  output: items,
1237
1242
  usage,
1238
- ...(status === "incomplete" ? { incomplete_details: { reason: "max_output_tokens" } } : {}),
1243
+ incomplete_details: incompleteDetailsForStatus(status),
1239
1244
  ...(status === "failed"
1240
1245
  ? { error: { message: message?.errorMessage ?? "response failed" } }
1241
1246
  : {}),
@@ -1260,6 +1265,7 @@ export function encodeStream(
1260
1265
  model: requestedModelId,
1261
1266
  output: [],
1262
1267
  error: { message: err instanceof Error ? err.message : String(err) },
1268
+ incomplete_details: null,
1263
1269
  },
1264
1270
  }),
1265
1271
  ),
@@ -1594,12 +1594,19 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
1594
1594
  const hasImages = toolResult.content.some((block): block is ImageContent => block.type === "image");
1595
1595
  const omittedImages = hasImages && !supportsImages;
1596
1596
  const normalized = normalizeResponsesToolCallId(toolResult.toolCallId);
1597
+ // "(see attached image)" is only truthful when the result actually carries
1598
+ // images (they ride as a separate user message on the Responses API). A
1599
+ // genuinely empty text result (empty file read, silent tool) must stay
1600
+ // empty — the placeholder sent models chasing an attachment that never
1601
+ // existed.
1597
1602
  const output = (
1598
1603
  omittedImages
1599
1604
  ? joinTextWithImagePlaceholder(textResult, true)
1600
1605
  : textResult.length > 0
1601
1606
  ? textResult
1602
- : "(see attached image)"
1607
+ : hasImages
1608
+ ? "(see attached image)"
1609
+ : ""
1603
1610
  ).toWellFormed();
1604
1611
  if (strictResponsesPairing && !knownCallIds.has(normalized.callId)) {
1605
1612
  // Strict backends (Azure, Copilot) reject unpaired outputs outright, but
@@ -299,7 +299,7 @@
299
299
  if (serverState.ok) {
300
300
  app.classList.add("success", "countdown");
301
301
  title.textContent = "Authentication Successful";
302
- message.innerHTML = "You have successfully logged in.<br>This window will close automatically.";
302
+ message.innerHTML = "You have successfully logged in.<br>You can now close this tab.";
303
303
  setTimeout(() => window.close(), 3000);
304
304
  } else {
305
305
  app.classList.add("error");
@@ -0,0 +1,169 @@
1
+ import type {
2
+ UsageAmount,
3
+ UsageFetchContext,
4
+ UsageFetchParams,
5
+ UsageLimit,
6
+ UsageProvider,
7
+ UsageReport,
8
+ UsageStatus,
9
+ UsageWindow,
10
+ } from "../usage";
11
+ import { toNumber } from "./shared";
12
+
13
+ function isRecord(value: unknown): value is Record<string, unknown> {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+
17
+ function parseTimestamp(value: unknown): number | undefined {
18
+ const numeric = toNumber(value);
19
+ if (numeric !== undefined) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
20
+ if (typeof value !== "string" || !value.trim()) return undefined;
21
+ const parsed = Date.parse(value);
22
+ return Number.isFinite(parsed) ? parsed : undefined;
23
+ }
24
+
25
+ function normalizeCursorBaseUrl(baseUrl?: string): string {
26
+ if (!baseUrl) return "https://api2.cursor.sh";
27
+ return baseUrl.replace(/\/+$/, "");
28
+ }
29
+
30
+ function deriveResetsAt(payload: Record<string, unknown>): number | undefined {
31
+ const endKeys = ["billingCycleEnd", "endOfMonth", "resetsAt", "nextReset"];
32
+ for (const key of endKeys) {
33
+ const parsed = parseTimestamp(payload[key]);
34
+ if (parsed !== undefined) return parsed;
35
+ }
36
+
37
+ const startKeys = ["startOfMonth", "billingCycleStart", "startOfBillingCycle"];
38
+ for (const key of startKeys) {
39
+ const parsed = parseTimestamp(payload[key]);
40
+ if (parsed !== undefined) {
41
+ const date = new Date(parsed);
42
+ date.setUTCMonth(date.getUTCMonth() + 1);
43
+ return date.getTime();
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
50
+ if (!isRecord(payload)) return null;
51
+ const limits: UsageLimit[] = [];
52
+ const resetsAt = deriveResetsAt(payload);
53
+
54
+ const window: UsageWindow = {
55
+ id: "monthly",
56
+ label: "Monthly",
57
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
58
+ };
59
+
60
+ for (const [key, value] of Object.entries(payload)) {
61
+ if (!isRecord(value)) continue;
62
+
63
+ const usedVal =
64
+ toNumber(value.numRequests) ?? toNumber(value.used) ?? toNumber(value.amountUsed) ?? toNumber(value.usdUsed);
65
+ const limitVal =
66
+ toNumber(value.maxRequestUsage) ??
67
+ toNumber(value.limit) ??
68
+ toNumber(value.amountLimit) ??
69
+ toNumber(value.usdLimit);
70
+
71
+ if (usedVal !== undefined && limitVal !== undefined) {
72
+ const isUsd =
73
+ key === "planUsage" ||
74
+ key.toLowerCase().includes("usd") ||
75
+ key.toLowerCase().includes("billing") ||
76
+ key.toLowerCase().includes("stripe");
77
+
78
+ const unit = isUsd ? "usd" : "requests";
79
+ const cleanBucket = key.toLowerCase().trim();
80
+ const limitId = isUsd ? `cursor:usd:${cleanBucket}` : `cursor:requests:${cleanBucket}`;
81
+ const label = isUsd ? `${key} spend` : `${key} requests`;
82
+
83
+ const amount: UsageAmount = {
84
+ used: usedVal,
85
+ limit: limitVal,
86
+ remaining: Math.max(0, limitVal - usedVal),
87
+ usedFraction: limitVal > 0 ? usedVal / limitVal : 0,
88
+ remainingFraction: limitVal > 0 ? Math.max(0, limitVal - usedVal) / limitVal : 0,
89
+ unit,
90
+ };
91
+
92
+ const usedFraction = amount.usedFraction;
93
+ let status: UsageStatus = "unknown";
94
+ if (usedFraction !== undefined) {
95
+ if (usedFraction >= 1) {
96
+ status = "exhausted";
97
+ } else if (usedFraction >= 0.9) {
98
+ status = "warning";
99
+ } else {
100
+ status = "ok";
101
+ }
102
+ }
103
+
104
+ limits.push({
105
+ id: limitId,
106
+ label,
107
+ scope: { provider: "cursor", windowId: window.id },
108
+ window,
109
+ amount,
110
+ status,
111
+ });
112
+ }
113
+ }
114
+
115
+ if (limits.length === 0) return null;
116
+
117
+ return {
118
+ provider: "cursor",
119
+ fetchedAt,
120
+ limits,
121
+ raw: payload,
122
+ };
123
+ }
124
+
125
+ export const cursorUsageProvider: UsageProvider = {
126
+ id: "cursor",
127
+ supports(params: UsageFetchParams): boolean {
128
+ if (params.provider !== "cursor") return false;
129
+ const { credential } = params;
130
+ if (credential.type === "oauth") return Boolean(credential.accessToken);
131
+ if (credential.type === "api_key") return Boolean(credential.apiKey);
132
+ return false;
133
+ },
134
+ async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
135
+ if (params.provider !== "cursor") return null;
136
+ const { credential } = params;
137
+ const token = credential.type === "oauth" ? credential.accessToken : credential.apiKey;
138
+ if (!token) return null;
139
+
140
+ const baseUrl = normalizeCursorBaseUrl(params.baseUrl ?? credential.apiEndpoint);
141
+ const url = `${baseUrl}/auth/usage`;
142
+ const headers: Record<string, string> = {
143
+ Accept: "application/json",
144
+ Authorization: `Bearer ${token}`,
145
+ };
146
+
147
+ try {
148
+ const response = await ctx.fetch(url, { headers, signal: params.signal });
149
+ if (!response.ok) {
150
+ ctx.logger?.warn("Cursor usage request failed", { status: response.status, provider: params.provider });
151
+ return null;
152
+ }
153
+ const payload = await response.json();
154
+ const report = parseCursorUsage(payload);
155
+ if (report) {
156
+ const metadata = {
157
+ ...(credential.email ? { email: credential.email } : {}),
158
+ ...(credential.accountId ? { accountId: credential.accountId } : {}),
159
+ ...(credential.projectId ? { projectId: credential.projectId } : {}),
160
+ };
161
+ if (Object.keys(metadata).length > 0) report.metadata = metadata;
162
+ }
163
+ return report;
164
+ } catch (error) {
165
+ ctx.logger?.warn("Cursor usage request error", { provider: params.provider, error: String(error) });
166
+ return null;
167
+ }
168
+ },
169
+ };
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * GET /wham/rate-limit-reset-credits → list redeemable credits
10
10
  * POST /wham/rate-limit-reset-credits/consume → spend one credit
11
- * body: { credit_id, redeem_request_id }
11
+ * body: { credit_id, redeem_request_id, account_id? }
12
12
  *
13
13
  * `redeem_request_id` is a client-generated idempotency key (UUID). The consume
14
14
  * response carries a `code`: `"reset"` on success, otherwise a business reason
@@ -159,7 +159,11 @@ export async function consumeCodexResetCredit(
159
159
  const response = await auth.fetch(url, {
160
160
  method: "POST",
161
161
  headers: buildHeaders(auth, true),
162
- body: JSON.stringify({ credit_id: auth.creditId, redeem_request_id: redeemRequestId }),
162
+ body: JSON.stringify({
163
+ credit_id: auth.creditId,
164
+ redeem_request_id: redeemRequestId,
165
+ account_id: auth.accountId,
166
+ }),
163
167
  signal: auth.signal,
164
168
  });
165
169
  let body: unknown;
@@ -581,7 +581,11 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
581
581
 
582
582
  const existingValue = mergedVariantFields[key];
583
583
  if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) {
584
- return schema;
584
+ if (key !== "description") return schema;
585
+ // Descriptions are annotations, so merge branch-local spill text instead of
586
+ // treating it as a structural incompatibility.
587
+ mergedVariantFields[key] = mergeSchemaDescriptions(existingValue, variantValue);
588
+ continue;
585
589
  }
586
590
  mergedVariantFields[key] = variantValue;
587
591
  }
@@ -622,7 +626,9 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
622
626
  const value = mergedVariantFields[key];
623
627
  const existingValue = nextSchema[key];
624
628
  if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
625
- return schema;
629
+ if (key !== "description") return schema;
630
+ nextSchema[key] = mergeSchemaDescriptions(existingValue, value);
631
+ continue;
626
632
  }
627
633
  if (existingValue === undefined) {
628
634
  nextSchema[key] = value;
@@ -631,6 +637,13 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
631
637
  return nextSchema;
632
638
  }
633
639
 
640
+ function mergeSchemaDescriptions(existing: unknown, incoming: unknown): string {
641
+ if (typeof existing !== "string") return typeof incoming === "string" ? incoming : "";
642
+ if (typeof incoming !== "string" || incoming.length === 0 || existing === incoming) return existing;
643
+ if (existing.length === 0) return incoming;
644
+ return `${existing}\n\n${incoming}`;
645
+ }
646
+
634
647
  function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
635
648
  const variantsRaw = schema[combiner];
636
649
  if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema;