@bitkyc08/opencodex 2.39.0 → 2.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -106,7 +106,13 @@ export interface ProviderRequestPacingConfig extends RequestPacingRule {
106
106
  }
107
107
 
108
108
  export interface FastWire {
109
- kind: "service-tier" | "anthropic-speed";
109
+ /**
110
+ * How the provider expresses Fast on the wire. `service-tier` is OpenAI's
111
+ * `service_tier` request field; `cursor-variant` is a MODEL-VARIANT switch, because
112
+ * Cursor has no tier field — its fast product is a different model id
113
+ * (`claude-opus-5-thinking-high-fast`) or a `{id:"fast"}` request parameter for Grok.
114
+ */
115
+ kind: "service-tier" | "anthropic-speed" | "cursor-variant";
110
116
  /** Canonical tier name to upstream wire spelling. */
111
117
  canonicalToWire: Readonly<Record<string, string>>;
112
118
  /** Policy for non-canonical caller-provided tier values. */
@@ -168,6 +174,8 @@ export interface OcxProviderConfig {
168
174
  alias?: string;
169
175
  /** Native model id -> short, slash-free request alias. */
170
176
  modelAliases?: Record<string, string>;
177
+ /** Display-only labels for exact native model ids discovered under this provider. */
178
+ modelDisplayNames?: Record<string, string>;
171
179
  /** Override the global built-in model-alias switch for this provider. */
172
180
  defaultAliases?: boolean;
173
181
  adapter: string;
@@ -275,6 +283,18 @@ export interface OcxProviderConfig {
275
283
  * (current behavior unchanged). Only meaningful for https: base URLs.
276
284
  */
277
285
  upstreamHttpVersion?: UpstreamHttpVersion;
286
+ /**
287
+ * Opt-in upstream Responses WebSocket transport for `openai-responses` requests. When true,
288
+ * streaming POST turns use the configured Responses path (default `/v1/responses`): forward
289
+ * providers use `{baseUrl}/responses`, while key-auth providers use `responsesPath` or the
290
+ * legacy `/v1/responses` fallback. HTTPS providers use wss and are re-encoded to SSE; HTTP
291
+ * providers continue using SSE, and `openai-chat` requests stay on HTTP. This mirrors the
292
+ * canonical ChatGPT backend optimization for any OpenAI-compatible gateway that speaks the
293
+ * Responses WebSocket protocol (for example an aggregator like sub2api whose WS ingress is
294
+ * measurably faster than its SSE queue). Default false. Canonical ChatGPT backend WS selection
295
+ * is independent of this flag.
296
+ */
297
+ upstreamWebsocket?: boolean;
278
298
  /**
279
299
  * Google only. When `false`, the AI Studio (direct) path sends Gemini Flash ids
280
300
  * unchanged to the wire instead of applying the `-tiered` suffix (`gemini-3.7-flash`
@@ -319,6 +339,16 @@ export interface OcxProviderConfig {
319
339
  * full set so the user can pick). See devlog issue_052_provider-model-allowlist.
320
340
  */
321
341
  selectedModels?: string[];
342
+ /**
343
+ * Per-provider retention allowlist for authoritative live discovery. When non-empty, any
344
+ * model id in this list is preserved in the routed catalog even if the live `/models`
345
+ * endpoint omits it (ad-hoc / private providers whose live discovery drops callable ids).
346
+ * Mirrors the built-in `kimi`/`xai` compatibility tables — opt-in for every other provider.
347
+ * Ids listed here need not be repeated in `models`: discovery folds them into the configured
348
+ * seed, so they exist under `liveModels: false` too. `selectedModels` still narrows what is
349
+ * visible. Empty/undefined = no opt-in (default behavior). See #1690.
350
+ */
351
+ retainModels?: string[];
322
352
  /** Override for newly discovered models. Absent/"inherit" uses the install policy. */
323
353
  newModelPolicy?: "on" | "off" | "inherit";
324
354
  /**
@@ -402,6 +432,13 @@ export interface OcxProviderConfig {
402
432
  */
403
433
  oauthAccountFailover?: {
404
434
  enabled?: boolean;
435
+ /**
436
+ * Generic OAuth pool selection strategy (#695). Persisted through the pool-settings
437
+ * contract; the selector does not consume it yet, so omitted keeps today's behavior.
438
+ */
439
+ strategy?: "quota" | "round-robin" | "fill-first";
440
+ /** 0-100 usage percent at which a proactive switch may be considered (#695); inert today. */
441
+ autoSwitchThreshold?: number;
405
442
  };
406
443
  /** Allow an explicitly key/oauth provider to run without a credential (for keyless local proxies). */
407
444
  keyOptional?: boolean;
@@ -504,6 +541,14 @@ export interface OcxProviderConfig {
504
541
  * per-model compatibility escape hatch for mixed-capability gateways.
505
542
  */
506
543
  noStructuredOutputModels?: string[];
544
+ /**
545
+ * Model ids that accept a reasoning-effort field on an ordinary turn but reject it
546
+ * once function tools are present. The model keeps its advertised effort ladder;
547
+ * OpenCodex omits the wire field for tool-bearing requests only and lets the
548
+ * upstream default apply. Narrower than `noReasoningModels`, which strips reasoning
549
+ * from every request and costs the model its picker entirely.
550
+ */
551
+ omitReasoningEffortWithToolsModels?: string[];
507
552
  /**
508
553
  * Allow multiple tool calls per completion. DEFAULT-ON for openai-chat providers (the
509
554
  * buffered stream parser assembles interleaved/fragmented multi-call turns safely);
@@ -596,6 +641,14 @@ export interface OcxProviderConfig {
596
641
  * thinking separately in `reasoning_content` / `reasoning_details` instead of visible content.
597
642
  */
598
643
  reasoningSplitModels?: string[];
644
+ /**
645
+ * Model ids whose chat endpoint carries thinking as a structured `reasoning_details` array
646
+ * (MiniMax M-series with `reasoning_split`): stream deltas repeat each detail's `text` as a
647
+ * cumulative snapshot, so the adapter prefix-diffs instead of appending, and preserved
648
+ * reasoning replays as a `reasoning_details` array rather than a `reasoning_content` string
649
+ * (upstream requires the array back verbatim to keep interleaved thinking intact).
650
+ */
651
+ reasoningDetailsModels?: string[];
599
652
  /**
600
653
  * Model ids whose reasoning is a vendor `thinking: {type}` toggle on the
601
654
  * chat-completions wire (MiMo v2.x, GLM 5/5.1 style), NOT an OpenAI `reasoning_effort` ladder.
@@ -333,6 +333,8 @@ export type AdapterEvent =
333
333
  | {
334
334
  type: "done";
335
335
  usage?: OcxUsage;
336
+ /** Native opaque compaction ciphertext returned by a Responses backend. */
337
+ compactionEncryptedContent?: string;
336
338
  stopReason?: string;
337
339
  endTurn?: boolean;
338
340
  providerState?: OcxProviderContinuationState;
package/src/types.ts CHANGED
@@ -63,11 +63,16 @@ export type {
63
63
  OcxApiKeyEntry,
64
64
  OcxClientIntegrationsConfig,
65
65
  OcxConfigRebaseProvenance,
66
+ OcxHubConfig,
67
+ OcxRemoteGuiConfig,
68
+ OcxConnectedClientId,
69
+ OcxClientConnectionConfig,
66
70
  OcxConfig,
67
71
  OcxAccountPoolRotationStrategy,
68
72
  OcxAccountPoolQuotaWindow,
69
73
  OcxComboStrategy,
70
74
  OcxComboDefaultEffort,
75
+ OcxComboReasoningEffortMode,
71
76
  OcxComboTarget,
72
77
  OcxComboConfig,
73
78
  OcxRoutingUnknownEvidenceMode,
package/src/update/job.ts CHANGED
@@ -51,6 +51,11 @@ const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/lates
51
51
  const UPDATE_JOB_FILENAME = "update-job.json";
52
52
  const UPDATE_TIMEOUT_MS = 180_000;
53
53
  const RESTART_TIMEOUT_MS = 60_000;
54
+ // A Windows `service repair` can spend up to 45s in its own serving probe after
55
+ // Task Scheduler/ACL work. The generic 60s child ceiling can kill that valid repair
56
+ // and launch a competing foreground proxy. Keep this below the update worker's 180s
57
+ // ceiling while covering the measured probe plus bounded Windows setup work.
58
+ const WINDOWS_SERVICE_REPAIR_TIMEOUT_MS = 150_000;
54
59
  const RESTART_HEALTH_TIMEOUT_MS = 30_000;
55
60
  const RESTART_STABILITY_WINDOW_MS = 15_000;
56
61
  /** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */
@@ -687,7 +692,12 @@ export function startUpdateJob(
687
692
  * and a bounded, structured summary — enough to tell a user which step failed and how, with no
688
693
  * free-form vendor text passing through the boundary. Detailed output stays ephemeral.
689
694
  */
690
- function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], timeout: number): { status: number | null; signal: NodeJS.Signals | null } {
695
+ function runLoggedCommand(
696
+ job: UpdateJobState,
697
+ bin: string,
698
+ args: string[],
699
+ timeout: number,
700
+ ): { status: number | null; signal: NodeJS.Signals | null; timedOut: boolean } {
691
701
  job = updateJob(job, {}, `$ ${formatCommand(bin, args)}`);
692
702
  const result = spawnSync(bin, args, {
693
703
  encoding: "utf8",
@@ -698,7 +708,11 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time
698
708
  const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
699
709
  const summary = summarizeCommandOutput(stdout, stderr, result.status, result.signal);
700
710
  if (summary) updateJob(job, {}, summary);
701
- return { status: result.status, signal: result.signal };
711
+ return {
712
+ status: result.status,
713
+ signal: result.signal,
714
+ timedOut: (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT",
715
+ };
702
716
  }
703
717
 
704
718
  /**
@@ -987,7 +1001,8 @@ export interface RestartIo {
987
1001
  job: UpdateJobState,
988
1002
  bin: string,
989
1003
  args: string[],
990
- ) => { status: number | null; signal?: NodeJS.Signals | null };
1004
+ timeoutMs: number,
1005
+ ) => { status: number | null; signal?: NodeJS.Signals | null; timedOut?: boolean };
991
1006
  /** Override the explicit restart path (used by finishGuiUpdateRestart tests). */
992
1007
  restartAfterUpdateFn?: (
993
1008
  job: UpdateJobState,
@@ -1155,10 +1170,23 @@ async function restartAfterUpdate(
1155
1170
  process.env.OCX_BAKE_PORT = String(Math.trunc(port));
1156
1171
  let serviceOk = false;
1157
1172
  try {
1158
- const run = io.runService ?? ((j, bin, args) => runLoggedCommand(j, bin, args, RESTART_TIMEOUT_MS));
1159
- const result = run(job, cmd.bin, cmd.args);
1173
+ const repairTimeoutMs = (io.platform ?? process.platform) === "win32"
1174
+ ? WINDOWS_SERVICE_REPAIR_TIMEOUT_MS
1175
+ : RESTART_TIMEOUT_MS;
1176
+ const run = io.runService ?? ((j, bin, args, timeoutMs) => runLoggedCommand(j, bin, args, timeoutMs));
1177
+ const result = run(job, cmd.bin, cmd.args, repairTimeoutMs);
1160
1178
  serviceOk = result.status === 0;
1161
1179
  if (!serviceOk) {
1180
+ if (result.timedOut) {
1181
+ // UAC and scheduler mutation can outlive a fixed child deadline. Once the
1182
+ // worker kills that child, ownership is ambiguous: launching a foreground
1183
+ // proxy here can race a registration that completes moments later.
1184
+ updateJob(job, {}, "Service repair timed out with Task Scheduler state unknown; refusing a competing direct start.");
1185
+ throw new Error(
1186
+ "Service repair timed out with Task Scheduler state unknown; refusing a competing direct start. "
1187
+ + "Run 'ocx service status', then 'ocx service repair' by hand.",
1188
+ );
1189
+ }
1162
1190
  // The refresh that just failed was `ocx service repair` (serviceReinstallArgs).
1163
1191
  // It normally reuses a healthy registration, but a stale definition may have tried
1164
1192
  // guarded re-registration/elevation. Advising `install` here would unconditionally
package/src/usage/cost.ts CHANGED
@@ -319,7 +319,8 @@ function resolveModelLevelPrice(provider: string, modelId: string): MatchedPrice
319
319
  // dots where the catalog uses dashes (kiro "claude-opus-4.6" vs anthropic
320
320
  // "claude-opus-4-6"). No fuzzy matching beyond this one normalization.
321
321
  const found = findVendorCostByModelId(modelId)
322
- ?? (modelId.includes(".") ? findVendorCostByModelId(modelId.replaceAll(".", "-")) : undefined);
322
+ ?? (modelId.includes(".") ? findVendorCostByModelId(modelId.replaceAll(".", "-")) : undefined)
323
+ ?? vendorPrefixedCost(modelId);
323
324
  if (!found) return null;
324
325
  return {
325
326
  provider,
@@ -331,6 +332,36 @@ function resolveModelLevelPrice(provider: string, modelId: string): MatchedPrice
331
332
  };
332
333
  }
333
334
 
335
+ /**
336
+ * Aggregators spell a model as `<vendor>/<model>` — CommandCode serves
337
+ * `deepseek/deepseek-v4-flash`, and OpenRouter-shaped presets do the same. The cost
338
+ * catalog stores the bare id, so the exact lookup above misses a price that is present and
339
+ * every request through such a provider reports no cost at all (#3136).
340
+ *
341
+ * Retrying on the tail is only safe while the prefix AGREES with the vendor the matched row
342
+ * belongs to. `findVendorCostByModelId` returns whichever vendor `COST_VENDOR_PRIORITY`
343
+ * reaches first, so an unchecked strip would happily price `openai/claude-opus-4-6` from
344
+ * Anthropic's row — a number that looks authoritative and is wrong. Requiring agreement
345
+ * keeps the failure closed for a genuinely mismatched id.
346
+ *
347
+ * Comparison is normalized because the same vendor is spelled differently across catalogs:
348
+ * `x-ai/grok-4.6` resolves to vendor `xai`. Dashes and case are the only variance seen;
349
+ * anything beyond that stays a miss.
350
+ */
351
+ function vendorPrefixedCost(modelId: string): ReturnType<typeof findVendorCostByModelId> {
352
+ const slash = modelId.indexOf("/");
353
+ if (slash <= 0 || slash === modelId.length - 1) return undefined;
354
+ const claimedVendor = modelId.slice(0, slash);
355
+ const tail = modelId.slice(slash + 1);
356
+ // A tail that is itself slashed is not a vendor prefix we understand; leave it alone.
357
+ if (tail.includes("/")) return undefined;
358
+ const found = findVendorCostByModelId(tail)
359
+ ?? (tail.includes(".") ? findVendorCostByModelId(tail.replaceAll(".", "-")) : undefined);
360
+ if (!found) return undefined;
361
+ const normalize = (value: string): string => value.toLowerCase().replaceAll("-", "");
362
+ return normalize(found.provider) === normalize(claimedVendor) ? found : undefined;
363
+ }
364
+
334
365
  function isEstimated(usage: OcxUsage, usageStatus: UsageStatus, priceStatus: ExpectedPriceStatus | "verified"): boolean {
335
366
  return usage.estimated === true || usageStatus === "estimated" || priceStatus === "verified-derived";
336
367
  }
@@ -66,6 +66,10 @@ const QWEN38_MAX: Cost4 = { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 };
66
66
  // Anthropic official list prices (USD / 1M tokens). Cache write uses the published 5-minute rate.
67
67
  const CLAUDE_SONNET_46: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
68
68
  const CLAUDE_OPUS_46: Cost4 = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 };
69
+ // Claude Fable 5.1: 10 / 50, 5m cache write 12.50. Cache hits are 0.025x base input
70
+ // (0.25) on Fable 5.1 — NOT the 0.1x (1.00) that Fable 5 and every other family use;
71
+ // the pricing page footnote calls this out explicitly. Verified 2026-09-02.
72
+ const CLAUDE_FABLE_51: Cost4 = { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 };
69
73
  // Opus 5 is priced from the maintainer's confirmation that it matches the previous
70
74
  // Opus, not from a published Opus 5 page. Hence `verified-derived`, and a source
71
75
  // string that states the provenance instead of pointing at ANTHROPIC_PRICING.
@@ -91,6 +95,18 @@ const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing (official table; cac
91
95
  const QWEN38_MAX_PRICING = "https://qwen.ai/blog?id=qwen3.8 (Qwen release announcement; no Model Studio billing row yet; cache rates unpublished -> 0)";
92
96
 
93
97
  export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
98
+ // claude-fable-5-1 has no jawcode row yet, so both Anthropic surfaces need their own
99
+ // overlay (the overlay lookup is keyed by the configured provider id; only the jawcode
100
+ // bundle collapses anthropic-apikey onto anthropic).
101
+ { provider: "anthropic", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input`, verifiedAt: "2026-09-02", status: "verified" },
102
+ { provider: "anthropic-apikey", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input`, verifiedAt: "2026-09-02", status: "verified" },
103
+ // Cursor seeds Fable 5.1 preemptively under three spellings (adapters/cursor/catalog.ts);
104
+ // the model-level vendor fallback only searches jawcode metadata, which has no Fable 5.1
105
+ // row yet, so each Cursor spelling needs its own overlay. Vendor list price, like the
106
+ // cursor/claude-opus-5 row.
107
+ { provider: "cursor", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" },
108
+ { provider: "cursor", modelId: "claude-fable-5.1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" },
109
+ { provider: "cursor", modelId: "claude-5.1-fable", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" },
94
110
  // claude-opus-5 is exposed by three providers but absent from the jawcode bundle, so
95
111
  // cost resolution returned null and the Logs `~$` column rendered an em dash. The
96
112
  // model-level vendor fallback only searches jawcode metadata, never overlays, so one
package/src/usage/log.ts CHANGED
@@ -322,7 +322,8 @@ function normalizeAttemptTierOutcome(raw: unknown): AttemptTierOutcome | null {
322
322
  if ("wireKind" in outcome
323
323
  && outcome.wireKind !== null
324
324
  && outcome.wireKind !== "service-tier"
325
- && outcome.wireKind !== "anthropic-speed") return null;
325
+ && outcome.wireKind !== "anthropic-speed"
326
+ && outcome.wireKind !== "cursor-variant") return null;
326
327
  if ("wireValue" in outcome && outcome.wireValue !== null && typeof outcome.wireValue !== "string") return null;
327
328
  if ("fastDowngradeReason" in outcome
328
329
  && (typeof outcome.fastDowngradeReason !== "string"
@@ -337,7 +338,10 @@ function normalizeAttemptTierOutcome(raw: unknown): AttemptTierOutcome | null {
337
338
  const responseServiceTier = sanitizeLogMetadataString(outcome.responseServiceTier);
338
339
  return {
339
340
  ...(outcome.canonical === "priority" ? { canonical: "priority" as const } : {}),
340
- ...(outcome.wireKind === null || outcome.wireKind === "service-tier" || outcome.wireKind === "anthropic-speed"
341
+ ...(outcome.wireKind === null
342
+ || outcome.wireKind === "service-tier"
343
+ || outcome.wireKind === "anthropic-speed"
344
+ || outcome.wireKind === "cursor-variant"
341
345
  ? { wireKind: outcome.wireKind }
342
346
  : {}),
343
347
  ...(outcome.wireValue === null
@@ -156,6 +156,7 @@ export interface UsageSummary {
156
156
  export interface UsageFilterEcho {
157
157
  provider: string | null;
158
158
  model: string | null;
159
+ apiKeyId: string | null;
159
160
  matched: boolean;
160
161
  /**
161
162
  * True when a retained row came from a combo attribution. Cost partitions
@@ -1129,6 +1130,11 @@ function normalizeFilterValue(input: string | null | undefined): string | null {
1129
1130
  return trimmed === "" ? null : trimmed.toLowerCase();
1130
1131
  }
1131
1132
 
1133
+ function normalizeExactFilterValue(input: string | null | undefined): string | null {
1134
+ const trimmed = typeof input === "string" ? input.trim() : "";
1135
+ return trimmed === "" ? null : trimmed;
1136
+ }
1137
+
1132
1138
  /**
1133
1139
  * Narrow an already-summarised window to one provider and/or model.
1134
1140
  *
@@ -1152,23 +1158,51 @@ function normalizeFilterValue(input: string | null | undefined): string | null {
1152
1158
  */
1153
1159
  export function projectUsageSummary<T extends UsageSummary>(
1154
1160
  summary: T,
1155
- filter: { provider?: string | null; model?: string | null },
1161
+ filter: { provider?: string | null; model?: string | null; apiKeyId?: string | null },
1156
1162
  entries?: PersistedUsageEntry[],
1157
1163
  ): T & { filter?: UsageFilterEcho } {
1158
1164
  const provider = normalizeFilterValue(filter.provider);
1159
1165
  const model = normalizeFilterValue(filter.model);
1160
- if (provider === null && model === null) return summary;
1166
+ const apiKeyId = normalizeExactFilterValue(filter.apiKeyId);
1167
+ if (provider === null && model === null && apiKeyId === null) return summary;
1168
+
1169
+ // Re-summarise from the entries the summary was built from, rather than
1170
+ // projecting over its rows.
1171
+ //
1172
+ // Projecting rows looked cheaper and was wrong in three ways that only show
1173
+ // up together: breakdown rows past MAX_USAGE_MODEL_BREAKDOWN_ROWS are
1174
+ // collapsed into a synthetic "other" row, so a provider living only in that
1175
+ // tail is unfindable and reports matched:false despite real usage; a
1176
+ // provider row is a whole-provider aggregate, so a model filter kept the
1177
+ // provider's OTHER models in providers[] while models[] and the totals
1178
+ // excluded them, contradicting itself inside one response; and a model row
1179
+ // carries a single optional cost, so priced/unpriced/unmetered counts could
1180
+ // only be guessed per model rather than counted per request.
1181
+ //
1182
+ // Key ownership is the outer slice: no provider/model attribution or bucket
1183
+ // construction may observe rows belonging to another client key.
1184
+ const keyFilteredEntries = apiKeyId === null
1185
+ ? entries ?? []
1186
+ : (entries ?? []).filter(entry => entry.apiKeyId === apiKeyId);
1161
1187
 
1188
+ // The entries are already in hand on every path that filters, so the honest
1189
+ // computation is also the simple one.
1162
1190
  const matches = (rowProvider: string, rowModel: string): boolean => {
1163
1191
  if (provider !== null && baseProviderLabel(rowProvider).toLowerCase() !== provider) return false;
1164
1192
  if (model !== null && rowModel.toLowerCase() !== model) return false;
1165
1193
  return true;
1166
1194
  };
1167
1195
 
1168
- const source = entries ?? [];
1196
+ // Narrow to matching ATTRIBUTIONS, not matching entries.
1197
+ //
1198
+ // Keeping a whole combo entry because one of its attempts matched drags the
1199
+ // other attempts' tokens and cost into the filtered totals: a two-attempt
1200
+ // combo filtered to its cheap model reported the expensive model's spend
1201
+ // too. Rewriting the entry down to its matching attempts is what makes the
1202
+ // filtered numbers mean what the flag says.
1169
1203
  let comboOverlap = false;
1170
1204
  const filtered: PersistedUsageEntry[] = [];
1171
- for (const entry of source) {
1205
+ for (const entry of keyFilteredEntries) {
1172
1206
  if (!entry.attempts?.length) {
1173
1207
  const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel);
1174
1208
  if (matches(entry.provider, identity.model)) filtered.push(entry);
@@ -1194,7 +1228,15 @@ export function projectUsageSummary<T extends UsageSummary>(
1194
1228
  days: projected.days.map(day => ({ ...day, models: day.models.filter(row => matches(row.provider, row.model)) })),
1195
1229
  models,
1196
1230
  providers: projected.providers.filter(row => retainedProviders.has(row.provider)),
1197
- accounts: [],
1198
- filter: { provider, model, matched, comboOverlap },
1231
+ // Account rows are not provider-partitioned in a way this projection could
1232
+ // honestly re-derive, and unfiltered account totals sitting beside filtered
1233
+ // model totals would invite exactly the wrong reading — so a provider or model
1234
+ // filter drops them.
1235
+ //
1236
+ // An apiKeyId-only filter is different: it selects whole entries, so the account
1237
+ // rows projected from those entries are exactly the accounts that key used. They
1238
+ // are honest under that filter and are kept.
1239
+ accounts: provider === null && model === null ? projected.accounts : [],
1240
+ filter: { provider, model, apiKeyId, matched, comboOverlap },
1199
1241
  };
1200
1242
  }