@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -4,8 +4,17 @@ import { usageDisplayTotalTokens } from "./totals";
4
4
  import { isCodexUsageAccountLogLabel, type PersistedUsageEntry, type UsageStatus } from "./log";
5
5
  import { estimateAttemptCost, estimateComboCost, estimateRequestCost, serviceTierContext } from "./cost";
6
6
 
7
- export type UsageRange = "7d" | "30d" | "all";
8
- export type UsageSurface = "all" | "codex" | "claude" | "grok";
7
+ /**
8
+ * Canonical range members. The warm-up loop in the management usage route
9
+ * iterates this constant rather than its own literal: the two used to be
10
+ * written separately, and a subset literal type-checks perfectly happily, so a
11
+ * range added to the union but forgotten in the loop was never warmed and
12
+ * never invalidated alongside its siblings.
13
+ */
14
+ export const USAGE_RANGES = ["today", "7d", "30d", "all"] as const;
15
+ export type UsageRange = typeof USAGE_RANGES[number];
16
+ export const USAGE_SURFACES = ["all", "codex", "claude", "grok"] as const;
17
+ export type UsageSurface = typeof USAGE_SURFACES[number];
9
18
 
10
19
  export interface UsageSummaryTotals {
11
20
  requests: number;
@@ -40,6 +49,8 @@ export interface UsageDay {
40
49
  measuredRequests: number;
41
50
  reportedRequests: number;
42
51
  totalTokens: number;
52
+ /** Display-time estimated cost for this local day, summed from its model rows. */
53
+ estimatedCostUsd: number;
43
54
  models: UsageDayModel[];
44
55
  }
45
56
 
@@ -49,6 +60,8 @@ export interface UsageDayModel {
49
60
  requests: number;
50
61
  attemptCount: number;
51
62
  totalTokens: number;
63
+ /** Display-time estimated cost attributed to this provider/model on this day. */
64
+ estimatedCostUsd: number;
52
65
  }
53
66
 
54
67
  export interface UsageModel {
@@ -113,6 +126,26 @@ export interface UsageSummary {
113
126
  accounts: UsageAccount[];
114
127
  }
115
128
 
129
+ /**
130
+ * Echo of an applied provider/model projection.
131
+ *
132
+ * Present only on a filtered response so a consumer can distinguish "no rows
133
+ * matched" from "no traffic in this window", and can tell that the totals it
134
+ * is reading are a projection rather than the whole window.
135
+ */
136
+ export interface UsageFilterEcho {
137
+ provider: string | null;
138
+ model: string | null;
139
+ matched: boolean;
140
+ /**
141
+ * True when a retained row came from a combo attribution. Cost partitions
142
+ * cleanly across attempts, but a combo request is counted once per
143
+ * participating model, so a filtered REQUEST count can exceed the number of
144
+ * distinct requests. Cost is unaffected.
145
+ */
146
+ comboOverlap: boolean;
147
+ }
148
+
116
149
  const DAY_MS = 86_400_000;
117
150
  export const MAX_USAGE_MODEL_BREAKDOWN_ROWS = 256;
118
151
 
@@ -127,6 +160,10 @@ function retainedBreakdownRows<T>(
127
160
  }
128
161
 
129
162
  export function parseRange(input: string | null | undefined): UsageRange {
163
+ // `1d` normalises here rather than becoming a second union member: a second
164
+ // member would need its own cache slot, its own grid arm and its own test
165
+ // matrix for no user-visible gain.
166
+ if (input === "today" || input === "1d") return "today";
130
167
  if (input === "7d" || input === "30d" || input === "all") return input;
131
168
  return "30d";
132
169
  }
@@ -143,6 +180,11 @@ function startOfLocalDay(ts: number): number {
143
180
  }
144
181
 
145
182
  export function rangeWindow(range: UsageRange, now: number): { since: number | null; days: number } {
183
+ // Handled before the others because the fallthrough below is the `all`
184
+ // window: a range that reaches it is silently reported as all-time history,
185
+ // which for a cost surface is a plausible-looking wrong answer rather than a
186
+ // visible failure.
187
+ if (range === "today") return { since: startOfLocalDay(now), days: 1 };
146
188
  if (range === "7d") {
147
189
  const start = new Date(startOfLocalDay(now));
148
190
  start.setDate(start.getDate() - 6);
@@ -335,6 +377,33 @@ function addEstimatedCost(
335
377
  totals.estimatedCostUsd += estimate.cost.total;
336
378
  }
337
379
 
380
+ /**
381
+ * Per-attribution cost for one entry, keyed by `provider/model`.
382
+ *
383
+ * Mirrors the attribution branch in {@link buildModels}: a combo request is
384
+ * priced per attempt and each attempt's cost belongs to its own model, so cost
385
+ * partitions across models rather than being counted once per participant. A
386
+ * single-target request contributes its whole cost to the entry's own model.
387
+ */
388
+ function dayAttributionCosts(entry: PersistedUsageEntry): Map<string, number> {
389
+ const costs = new Map<string, number>();
390
+ const tier = serviceTierContext(entry);
391
+ const estimate = entry.attempts?.length
392
+ ? estimateComboCost(entry.attempts, undefined, tier)
393
+ : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
394
+ if (!estimate) return costs;
395
+ const add = (provider: string, model: string, amount: number): void => {
396
+ const key = usageModelKey(baseProviderLabel(provider), antigravityUsageModel(provider, model));
397
+ costs.set(key, (costs.get(key) ?? 0) + amount);
398
+ };
399
+ if (entry.attempts?.length && estimate.attempts) {
400
+ for (const attempt of estimate.attempts) add(attempt.provider, attempt.model, attempt.cost.total);
401
+ } else {
402
+ add(entry.provider, entry.model, estimate.cost.total);
403
+ }
404
+ return costs;
405
+ }
406
+
338
407
  function buildDayGrid(range: UsageRange, since: number | null, now: number, entries: PersistedUsageEntry[]): UsageDay[] {
339
408
  const window = rangeWindow(range, now);
340
409
  const days = range === "all" ? dayCountForAllRange(entries, now) : window.days;
@@ -343,14 +412,14 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr
343
412
  // render a per-model stacked bar with a hover tooltip without a second pass over the entries.
344
413
  const dayModels = new Map<string, Map<string, UsageDayModel>>();
345
414
  const dayModelRequests = new Map<string, Set<string>>();
346
- const bumpDayModel = (dayKey: string, attribution: UsageAttribution): void => {
415
+ const bumpDayModel = (dayKey: string, attribution: UsageAttribution, costUsd: number): void => {
347
416
  let models = dayModels.get(dayKey);
348
417
  if (!models) { models = new Map(); dayModels.set(dayKey, models); }
349
418
  const providerKey = baseProviderLabel(attribution.provider);
350
419
  const mKey = usageModelKey(providerKey, attribution.model);
351
420
  let m = models.get(mKey);
352
421
  if (!m) {
353
- m = { model: attribution.model, provider: providerKey, requests: 0, attemptCount: 0, totalTokens: 0 };
422
+ m = { model: attribution.model, provider: providerKey, requests: 0, attemptCount: 0, totalTokens: 0, estimatedCostUsd: 0 };
354
423
  models.set(mKey, m);
355
424
  }
356
425
  const requestKey = `${dayKey}\0${mKey}`;
@@ -360,26 +429,44 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr
360
429
  m.requests = requests.size;
361
430
  m.attemptCount += 1;
362
431
  m.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0;
432
+ m.estimatedCostUsd += costUsd;
363
433
  };
364
434
  const startOfToday = startOfLocalDay(now);
365
435
  for (let i = days - 1; i >= 0; i--) {
366
436
  const d = new Date(startOfToday);
367
437
  d.setDate(d.getDate() - i);
368
438
  const key = localDateKey(d.getTime());
369
- grid.set(key, { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, models: [] });
439
+ grid.set(key, { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, estimatedCostUsd: 0, models: [] });
370
440
  }
371
441
  for (const entry of entries) {
372
442
  const key = localDateKey(entry.timestamp);
373
443
  let day = grid.get(key);
374
444
  if (!day) {
375
- day = { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, models: [] };
445
+ day = { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, estimatedCostUsd: 0, models: [] };
376
446
  grid.set(key, day);
377
447
  }
378
448
  day.requests += 1;
379
449
  if (isMeasuredStatus(entry.usageStatus)) day.measuredRequests += 1;
380
450
  if (entry.usageStatus === "reported") day.reportedRequests += 1;
381
451
  day.totalTokens += usageDisplayTotalTokens(entry.usage, entry.totalTokens) ?? 0;
382
- for (const attribution of usageAttributions(entry)) bumpDayModel(key, attribution);
452
+ // Price through the same seam buildModels uses: combo attempts are priced
453
+ // per attempt and attributed to their own model, everything else to the
454
+ // entry's model. Re-deriving a price here would make days[] disagree with
455
+ // models[] for exactly the combo traffic where nobody would notice.
456
+ const attributionCosts = dayAttributionCosts(entry);
457
+ for (const attribution of usageAttributions(entry)) {
458
+ const attributionKey = usageModelKey(baseProviderLabel(attribution.provider), attribution.model);
459
+ // Spend each key's cost ONCE. `attributionCosts` already holds the SUM of every
460
+ // attempt that shares a provider/model key, while `usageAttributions` yields one
461
+ // entry per attempt — so a retry onto the same model would otherwise add that
462
+ // pair's total twice and double the day against `summary.estimatedCostUsd`.
463
+ // Deleting on read keeps the first attribution carrying the group's cost and gives
464
+ // its siblings zero, which is what `buildModels` already does per attempt.
465
+ const costUsd = attributionCosts.get(attributionKey) ?? 0;
466
+ attributionCosts.delete(attributionKey);
467
+ bumpDayModel(key, attribution, costUsd);
468
+ day.estimatedCostUsd += costUsd;
469
+ }
383
470
  }
384
471
  void since;
385
472
  const out = [...grid.values()].sort((a, b) => a.date.localeCompare(b.date));
@@ -391,13 +478,15 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr
391
478
  const requests = new Set<string>();
392
479
  let attemptCount = 0;
393
480
  let totalTokens = 0;
481
+ let estimatedCostUsd = 0;
394
482
  for (const model of overflow) {
395
483
  attemptCount += model.attemptCount;
396
484
  totalTokens += model.totalTokens;
485
+ estimatedCostUsd += model.estimatedCostUsd;
397
486
  const requestKey = `${day.date}\0${usageModelKey(model.provider, model.model)}`;
398
487
  for (const requestId of dayModelRequests.get(requestKey) ?? []) requests.add(requestId);
399
488
  }
400
- return { model: "other", provider: "other", requests: requests.size, attemptCount, totalTokens };
489
+ return { model: "other", provider: "other", requests: requests.size, attemptCount, totalTokens, estimatedCostUsd };
401
490
  });
402
491
  }
403
492
  }
@@ -595,6 +684,16 @@ function legacyCodexAccountLabel(provider: string): string | null {
595
684
  return suffix ?? LEGACY_AMBIGUOUS_ACCOUNT_LABEL;
596
685
  }
597
686
 
687
+ /**
688
+ * An explicitly stamped label of EITHER family is authoritative for any provider (#2699).
689
+ *
690
+ * No `o`-label branch is needed here: `isCodexUsageAccountLogLabel` now accepts both families,
691
+ * and adding a second predicate call would be a no-op guarded by a comment claiming otherwise.
692
+ *
693
+ * The legacy fallback stays openai-only on purpose. It infers an account from the PROVIDER
694
+ * string, and inferring for a non-Codex row would merge unrelated accounts under one label --
695
+ * so an unlabeled xai row is dropped from the account table rather than guessed at.
696
+ */
598
697
  function accountLabelForAttribution(provider: string, explicit: unknown): string | null {
599
698
  if (isCodexUsageAccountLogLabel(explicit)) return explicit;
600
699
  return legacyCodexAccountLabel(provider);
@@ -751,3 +850,107 @@ export function summarizeUsage(
751
850
  accounts: buildAccounts(filteredEntries),
752
851
  };
753
852
  }
853
+
854
+ function normalizeFilterValue(input: string | null | undefined): string | null {
855
+ const trimmed = typeof input === "string" ? input.trim() : "";
856
+ return trimmed === "" ? null : trimmed.toLowerCase();
857
+ }
858
+
859
+ /**
860
+ * Narrow an already-summarised window to one provider and/or model.
861
+ *
862
+ * Deliberately a projection over a finished summary rather than a parameter to
863
+ * {@link summarizeUsage}. The management route caches summaries under
864
+ * `range:surface` and warms that key space as a cross-product; a filtered
865
+ * summary that reached either would be served to the next UNFILTERED caller,
866
+ * the dashboard included. Keeping the filter outside the producer makes that
867
+ * mistake unrepresentable rather than merely discouraged.
868
+ *
869
+ * Totals are recomputed from the retained rows. For combo traffic a request is
870
+ * counted once per participating model, so a filtered request count can exceed
871
+ * the number of distinct requests; `comboOverlap` reports when that is
872
+ * possible. Cost is unaffected — combo cost is attributed per attempt, so it
873
+ * partitions across models rather than repeating.
874
+ *
875
+ * `accounts` is emptied whenever a filter is active: account rows are not
876
+ * provider-partitioned in a way this projection could honestly re-derive, and
877
+ * unfiltered account totals sitting beside filtered model totals would invite
878
+ * exactly the wrong reading.
879
+ */
880
+ export function projectUsageSummary<T extends UsageSummary>(
881
+ summary: T,
882
+ filter: { provider?: string | null; model?: string | null },
883
+ entries?: PersistedUsageEntry[],
884
+ ): T & { filter?: UsageFilterEcho } {
885
+ const provider = normalizeFilterValue(filter.provider);
886
+ const model = normalizeFilterValue(filter.model);
887
+ if (provider === null && model === null) return summary;
888
+
889
+ // Re-summarise from the entries the summary was built from, rather than
890
+ // projecting over its rows.
891
+ //
892
+ // Projecting rows looked cheaper and was wrong in three ways that only show
893
+ // up together: breakdown rows past MAX_USAGE_MODEL_BREAKDOWN_ROWS are
894
+ // collapsed into a synthetic "other" row, so a provider living only in that
895
+ // tail is unfindable and reports matched:false despite real usage; a
896
+ // provider row is a whole-provider aggregate, so a model filter kept the
897
+ // provider's OTHER models in providers[] while models[] and the totals
898
+ // excluded them, contradicting itself inside one response; and a model row
899
+ // carries a single optional cost, so priced/unpriced/unmetered counts could
900
+ // only be guessed per model rather than counted per request.
901
+ //
902
+ // The entries are already in hand on every path that filters, so the honest
903
+ // computation is also the simple one.
904
+ const matches = (rowProvider: string, rowModel: string): boolean => {
905
+ if (provider !== null && baseProviderLabel(rowProvider).toLowerCase() !== provider) return false;
906
+ if (model !== null && rowModel.toLowerCase() !== model) return false;
907
+ return true;
908
+ };
909
+
910
+ // Narrow to matching ATTRIBUTIONS, not matching entries.
911
+ //
912
+ // Keeping a whole combo entry because one of its attempts matched drags the
913
+ // other attempts' tokens and cost into the filtered totals: a two-attempt
914
+ // combo filtered to its cheap model reported the expensive model's spend
915
+ // too. Rewriting the entry down to its matching attempts is what makes the
916
+ // filtered numbers mean what the flag says.
917
+ const source = entries ?? [];
918
+ let comboOverlap = false;
919
+ const filtered: PersistedUsageEntry[] = [];
920
+ for (const entry of source) {
921
+ if (!entry.attempts?.length) {
922
+ if (matches(entry.provider, antigravityUsageModel(entry.provider, entry.model))) filtered.push(entry);
923
+ continue;
924
+ }
925
+ const attempts = entry.attempts.filter(a => matches(a.provider, antigravityUsageModel(a.provider, a.model)));
926
+ if (attempts.length === 0) continue;
927
+ // A combo is still counted once per participating model, so a filtered
928
+ // request count can exceed the number of distinct requests. That is the
929
+ // documented overlap, and it is why comboOverlap exists.
930
+ if (entry.attempts.length > 1) comboOverlap = true;
931
+ filtered.push({ ...entry, attempts });
932
+ }
933
+
934
+ const projected = summarizeUsage(filtered, summary.range, summary.generatedAt, summary.surface);
935
+ // matched reflects usage inside the requested WINDOW, not anywhere in the
936
+ // log: summarizeUsage applies the range and surface predicates, and the CLI
937
+ // uses this flag to decide between a table and "no usage recorded".
938
+ const matched = projected.summary.requests > 0;
939
+ // A combo entry survives the entry filter as a whole, so its non-matching
940
+ // attributions can still appear as rows. Drop those so every row in the
941
+ // response satisfies the filter the caller asked for.
942
+ const models = projected.models.filter(row => matches(row.provider, row.model));
943
+ const retainedProviders = new Set(models.map(row => row.provider));
944
+ return {
945
+ ...summary,
946
+ summary: projected.summary,
947
+ days: projected.days.map(day => ({ ...day, models: day.models.filter(row => matches(row.provider, row.model)) })),
948
+ models,
949
+ providers: projected.providers.filter(row => retainedProviders.has(row.provider)),
950
+ // Account rows are not provider-partitioned in a way this projection could
951
+ // honestly re-derive, and unfiltered account totals sitting beside filtered
952
+ // model totals would invite exactly the wrong reading.
953
+ accounts: [],
954
+ filter: { provider, model, matched, comboOverlap },
955
+ };
956
+ }
@@ -102,26 +102,31 @@ export async function describeImage(
102
102
  }),
103
103
  { abortSignal: linkedSignal.signal, label: "vision-sidecar" },
104
104
  );
105
- recordOutcome?.(res.status);
106
- if (!res.ok) {
107
- const t = await res.text().catch(() => "");
108
- console.warn(`[vision] sidecar HTTP ${res.status} (${Date.now() - t0}ms)`);
109
- return { text: "", error: `vision sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
110
- }
111
105
  const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
112
- let parsed;
113
106
  try {
114
- parsed = await parseSidecarSSE(res);
107
+ if (!res.ok) {
108
+ recordOutcome?.(res.status);
109
+ const t = await res.text().catch(() => "");
110
+ console.warn(`[vision] sidecar HTTP ${res.status} (${Date.now() - t0}ms)`);
111
+ return { text: "", error: `vision sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
112
+ }
113
+ const parsed = await parseSidecarSSE(res);
114
+ if (linkedSignal.signal.aborted) throw linkedSignal.signal.reason;
115
+ recordOutcome?.(res.status);
116
+ // The backend can return HTTP 200 then stream a `response.failed`/`error` event with no text;
117
+ // surface that as a describe error instead of an empty (silently-blank) description.
118
+ if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error };
119
+ return { text: parsed.text };
115
120
  } finally {
116
121
  detachBodyGuard();
117
122
  }
118
- // The backend can return HTTP 200 then stream a `response.failed`/`error` event with no text;
119
- // surface that as a describe error instead of an empty (silently-blank) description.
120
- if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error };
121
- return { text: parsed.text };
122
123
  } catch (e) {
123
- recordOutcome?.(e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error");
124
124
  const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
125
+ const callerAborted = abortSignal?.aborted === true
126
+ && linkedSignal.signal.aborted
127
+ && linkedSignal.signal.reason === abortSignal.reason
128
+ && e === linkedSignal.signal.reason;
129
+ recordOutcome?.(callerAborted ? "connect_neutral" : kind);
125
130
  console.warn(`[vision] sidecar ${kind} (${Date.now() - t0}ms)`);
126
131
  return { text: "", error: e instanceof Error ? e.message : String(e) };
127
132
  } finally {
@@ -9,10 +9,11 @@
9
9
  * Never throws; every error string passes redactSecretString.
10
10
  */
11
11
  import { fetchWithResetRetry } from "../lib/upstream-retry";
12
- import { signalWithTimeout } from "../lib/abort";
12
+ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
13
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
13
14
  import { sidecarEnter } from "../lib/sidecar-tracker";
14
15
  import { redactSecretString } from "../lib/redact";
15
- import type { WebSearchSource } from "./parse";
16
+ import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse";
16
17
  import type { SidecarOutcome, SidecarSettings } from "./executor";
17
18
 
18
19
  const EXA_SEARCH_URL = "https://api.exa.ai/search";
@@ -47,14 +48,44 @@ export async function runExaWebSearch(
47
48
  }),
48
49
  { abortSignal: linkedSignal.signal, label: "exa-web-search-sidecar" },
49
50
  );
50
- if (!res.ok) {
51
- const t = await res.text().catch(() => "");
52
- // Scrub BEFORE truncating: slicing first can cut the literal key at the
53
- // boundary, leaving an unscrubbable key prefix in the surviving text.
54
- return { text: "", sources: [], error: `exa sidecar HTTP ${res.status}: ${scrub(t).slice(0, 200)}` };
51
+ const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
52
+ try {
53
+ let bounded: Awaited<ReturnType<typeof readBoundedResponseBytes>> | null = null;
54
+ try {
55
+ bounded = await readBoundedResponseBytes(res, {
56
+ maxBytes: MAX_SIDECAR_RESPONSE_BYTES,
57
+ signal: linkedSignal.signal,
58
+ });
59
+ } catch {
60
+ const reason = linkedSignal.signal.reason;
61
+ if (linkedSignal.signal.aborted && reason instanceof Error && reason.name === "TimeoutError") {
62
+ throw reason;
63
+ }
64
+ // Preserve the previous status-only/shapeless fallback for body read
65
+ // failures; fetch/header failures are still classified by the outer catch.
66
+ }
67
+ if (bounded?.oversized) {
68
+ const prefix = res.ok ? "exa sidecar response" : `exa sidecar HTTP ${res.status} response`;
69
+ return { text: "", sources: [], error: `${prefix} exceeded byte bound` };
70
+ }
71
+ // Response.text()/json() replace malformed UTF-8, so preserve that
72
+ // behavior while moving the byte bound ahead of decoding and parsing.
73
+ const text = bounded ? new TextDecoder().decode(bounded.bytes) : "";
74
+ if (!res.ok) {
75
+ // Scrub BEFORE truncating: slicing first can cut the literal key at the
76
+ // boundary, leaving an unscrubbable key prefix in the surviving text.
77
+ return { text: "", sources: [], error: `exa sidecar HTTP ${res.status}: ${scrub(text).slice(0, 200)}` };
78
+ }
79
+ let payload: unknown = null;
80
+ try {
81
+ payload = JSON.parse(text);
82
+ } catch {
83
+ // The mapper owns the stable malformed/empty JSON outcome.
84
+ }
85
+ return mapExaSearchResponse(payload);
86
+ } finally {
87
+ detachBodyGuard();
55
88
  }
56
- const payload = await res.json().catch(() => null);
57
- return mapExaSearchResponse(payload);
58
89
  } catch (e) {
59
90
  const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
60
91
  console.warn(`[web-search] exa sidecar ${kind} (${Date.now() - t0}ms)`);
@@ -85,25 +85,32 @@ export async function runWebSearch(
85
85
  }),
86
86
  { abortSignal: linkedSignal.signal, label: "web-search-sidecar" },
87
87
  );
88
- recordOutcome?.(res.status);
89
88
  // Attach the body guard before ANY branch reads it. The success path guarded itself below,
90
89
  // but the failure branch's `res.text()` runs first, so a cancel landing between fetch
91
90
  // resolution and reader attach orphaned the internal rejection (found investigating #1419).
92
91
  const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
93
92
  if (!res.ok) {
93
+ recordOutcome?.(res.status);
94
94
  const t = await res.text().catch(() => "");
95
95
  detachBodyGuard();
96
96
  console.warn(`[web-search] sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
97
97
  return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
98
98
  }
99
99
  try {
100
- return await parseSidecarSSE(res);
100
+ const parsed = await parseSidecarSSE(res);
101
+ if (linkedSignal.signal.aborted) throw linkedSignal.signal.reason;
102
+ recordOutcome?.(res.status);
103
+ return parsed;
101
104
  } finally {
102
105
  detachBodyGuard();
103
106
  }
104
107
  } catch (e) {
105
- recordOutcome?.(e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error");
106
108
  const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
109
+ const callerAborted = abortSignal?.aborted === true
110
+ && linkedSignal.signal.aborted
111
+ && linkedSignal.signal.reason === abortSignal.reason
112
+ && e === linkedSignal.signal.reason;
113
+ recordOutcome?.(callerAborted ? "connect_neutral" : kind);
107
114
  console.warn(`[web-search] sidecar ${kind} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
108
115
  return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) };
109
116
  } finally {
@@ -304,10 +304,11 @@ export interface WebSearchLoopDeps {
304
304
  /** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */
305
305
  onAttemptSend?: (recovery?: AttemptRecoveryKind) => void;
306
306
  /**
307
- * 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter,
308
- * or null when the pool is exhausted (same semantics as the normal routed path).
307
+ * 429 failover hook: rotate the provider's active credential and return a rebuilt adapter,
308
+ * or null when the pool is exhausted. Async hooks support OAuth refresh; existing synchronous
309
+ * key-pool hooks remain valid.
309
310
  */
310
- on429?: (retryAfterHeader: string | null) => ProviderAdapter | null;
311
+ on429?: (retryAfterHeader: string | null) => ProviderAdapter | null | Promise<ProviderAdapter | null>;
311
312
  /** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */
312
313
  retryOn429Policy?: Required<RateLimitRetryPolicy> | null;
313
314
  /** Called only when the final bridged Responses stream reaches completed or incomplete. */
@@ -509,7 +510,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
509
510
  // 429 key-failover parity with the normal routed path: rotate pool keys until one responds
510
511
  // or the pool is exhausted (deps.on429 returns null — cooldown map guarantees termination).
511
512
  while (prepared.response.status === 429 && deps.on429) {
512
- const rotated = deps.on429(prepared.response.headers.get("retry-after"));
513
+ const rotated = await deps.on429(prepared.response.headers.get("retry-after"));
513
514
  if (!rotated) break;
514
515
  // Never let a broken body's cancel promise outlive the cumulative header deadline. Observe
515
516
  // it, but proceed immediately to the rotated fetch under the SAME deadline signal.