@yansigit/opencodex 2.31.2 → 2.32.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 (118) hide show
  1. package/bin/ocx.mjs +99 -70
  2. package/gui/dist/assets/index-BG43zwVe.js +102 -0
  3. package/gui/dist/assets/index-CiSI-jrP.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +6 -8
  7. package/src/adapters/base.ts +2 -0
  8. package/src/adapters/command-code-project-context.ts +377 -0
  9. package/src/adapters/command-code.ts +7 -4
  10. package/src/adapters/cursor/live-models.ts +8 -0
  11. package/src/adapters/cursor/live-transport.ts +21 -0
  12. package/src/adapters/cursor/native-exec-bridge.ts +141 -0
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/tool-definitions.ts +1 -1
  15. package/src/adapters/google-http.ts +12 -2
  16. package/src/adapters/google-wire-compiler.ts +83 -2
  17. package/src/adapters/google.ts +62 -21
  18. package/src/adapters/kiro.ts +0 -3
  19. package/src/adapters/openai-responses.ts +3 -0
  20. package/src/adapters/tool-catalog-nudge.ts +1 -1
  21. package/src/adapters/xai-web-search.ts +7 -2
  22. package/src/bridge.ts +21 -15
  23. package/src/cli/dispatch.ts +50 -2
  24. package/src/cli/doctor.ts +24 -11
  25. package/src/cli/help.ts +4 -3
  26. package/src/cli/index.ts +11 -4
  27. package/src/cli/models.ts +13 -3
  28. package/src/cli/observe.ts +20 -5
  29. package/src/cli/provider.ts +2 -1
  30. package/src/cli/registry.ts +7 -5
  31. package/src/cli/status.ts +2 -1
  32. package/src/cli/system-restart-client.ts +1 -1
  33. package/src/cli/usage-report.ts +134 -0
  34. package/src/codex/app-server-processes.ts +3 -1
  35. package/src/codex/catalog/aggregation.ts +13 -1
  36. package/src/codex/catalog/effort.ts +24 -9
  37. package/src/codex/catalog/model-metadata.ts +566 -0
  38. package/src/codex/catalog/parsing.ts +33 -0
  39. package/src/codex/catalog/provider-fetch.ts +123 -32
  40. package/src/codex/catalog/sync.ts +5 -4
  41. package/src/codex/desktop-app-restart.ts +342 -0
  42. package/src/codex/history-job.ts +32 -3
  43. package/src/codex/history-manifest.ts +112 -0
  44. package/src/codex/history-migration-guardian.ts +5 -5
  45. package/src/codex/history-provider.ts +825 -247
  46. package/src/codex/history-worker.ts +8 -5
  47. package/src/codex/inject.ts +49 -21
  48. package/src/codex/injected-marker.ts +1 -1
  49. package/src/codex/internal/history-writer.ts +4 -3
  50. package/src/codex/native-profile-startup.ts +157 -27
  51. package/src/codex/native-residue.ts +26 -33
  52. package/src/combos/failover.ts +27 -0
  53. package/src/compatibility/index.ts +26 -0
  54. package/src/compatibility/manifest.ts +253 -0
  55. package/src/compatibility/openai-responses.ts +81 -0
  56. package/src/config/atomic-write.ts +219 -0
  57. package/src/config/paths.ts +40 -0
  58. package/src/config/process-state.ts +308 -0
  59. package/src/config/provider-validation.ts +177 -0
  60. package/src/config.ts +77 -812
  61. package/src/generated/compatibility-version.json +162 -94
  62. package/src/images/plan.ts +5 -4
  63. package/src/integrations/ownership-policy.ts +141 -0
  64. package/src/integrations/ownership.ts +10 -0
  65. package/src/integrations/state.ts +44 -5
  66. package/src/integrations/writer.ts +6 -0
  67. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  68. package/src/lib/bounded-body.ts +14 -2
  69. package/src/lib/process-control.ts +2 -1
  70. package/src/lib/state-store-registrations.ts +2 -0
  71. package/src/lib/tool-argument-integers.ts +56 -5
  72. package/src/oauth/health.ts +1 -1
  73. package/src/oauth/index.ts +3 -0
  74. package/src/providers/registry.ts +1 -1
  75. package/src/reasoning-effort.ts +19 -2
  76. package/src/responses/apply-patch-envelope.ts +63 -0
  77. package/src/responses/custom-tool-compat.ts +132 -38
  78. package/src/responses/parser.ts +3 -2
  79. package/src/responses/reasoning-replay-cache.ts +81 -3
  80. package/src/routing/compatibility/behavior.ts +3 -0
  81. package/src/server/auth-cors.ts +9 -7
  82. package/src/server/index.ts +102 -21
  83. package/src/server/local-management-read-client.ts +1 -1
  84. package/src/server/local-provider-reload-client.ts +1 -1
  85. package/src/server/management/agent-settings-routes.ts +1 -1
  86. package/src/server/management/config-routes.ts +4 -1
  87. package/src/server/management/context.ts +1 -1
  88. package/src/server/management/logs-usage-routes.ts +27 -6
  89. package/src/server/management/model-routes.ts +8 -4
  90. package/src/server/management/native-integration-routes.ts +2 -1
  91. package/src/server/management/provider-capability-config.ts +1 -1
  92. package/src/server/management/system-restart.ts +1 -1
  93. package/src/server/port-reclaim.ts +1 -1
  94. package/src/server/proxy-liveness.ts +2 -1
  95. package/src/server/request-log-conversation.ts +30 -0
  96. package/src/server/responses/codex-auth-error.ts +55 -0
  97. package/src/server/responses/combo-stream-preflight.ts +171 -0
  98. package/src/server/responses/compact.ts +6 -21
  99. package/src/server/responses/core.ts +242 -101
  100. package/src/server/responses/fetch-helpers.ts +2 -97
  101. package/src/server/responses-custom-tool-repair.ts +41 -5
  102. package/src/server/responses-undeclared-tool-guard.ts +156 -15
  103. package/src/service.ts +8 -4
  104. package/src/types/provider.ts +7 -0
  105. package/src/types/request.ts +12 -1
  106. package/src/types/tools.ts +87 -11
  107. package/src/types.ts +1 -1
  108. package/src/update/index.ts +5 -4
  109. package/src/update/job.ts +3 -1
  110. package/src/update/transactional-install.mjs +8 -1
  111. package/src/usage/log.ts +16 -8
  112. package/src/usage/summary.ts +201 -8
  113. package/src/vision/describe.ts +18 -13
  114. package/src/web-search/executor.ts +10 -3
  115. package/src/web-search/gemini-executor.ts +35 -13
  116. package/src/web-search/index.ts +85 -1
  117. package/gui/dist/assets/index-BAMgarF9.js +0 -102
  118. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
package/src/types.ts CHANGED
@@ -5,6 +5,7 @@ export type { OcxTool, OcxToolChoice } from "./types/tools";
5
5
  export {
6
6
  namespacedToolName,
7
7
  toolChoiceAliases,
8
+ createToolChoiceResolver,
8
9
  toolChoiceCandidates,
9
10
  toolAllowedByChoice,
10
11
  resolveToolChoiceWireName,
@@ -103,4 +104,3 @@ export type {
103
104
  CodexAccountCredentials,
104
105
  CodexAccountCredentialRecord,
105
106
  } from "./types/accounts";
106
-
@@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { readFileSync, readdirSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
- import { getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
5
+ import { getConfigDir, loadConfig } from "../config";
6
+ import { readPid, readRuntimePort } from "../config/process-state";
6
7
  import { npmInvocation } from "./npm-invocation.mjs";
7
8
  import {
8
9
  npmCachePreflightFailureMessage,
@@ -279,9 +280,9 @@ export async function runUpdate(): Promise<void> {
279
280
  }
280
281
  if (historyRestoreIncomplete()) {
281
282
  console.warn(
282
- "⚠️ Codex resume history was NOT restored (history DB locked — Codex app/IDE open?).\n" +
283
- " Your routed threads stay hidden in the native Codex app until restored.\n" +
284
- " After the update: close the Codex app, then run 'ocx stop' once to restore.",
283
+ "⚠️ Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" +
284
+ " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" +
285
+ " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.",
285
286
  );
286
287
  }
287
288
  }
package/src/update/job.ts CHANGED
@@ -6,12 +6,14 @@ import {
6
6
  atomicWriteFile,
7
7
  getConfigDir,
8
8
  loadConfig,
9
+ } from "../config";
10
+ import {
9
11
  readPid,
10
12
  readRuntimePort,
11
13
  removePid,
12
14
  removeRuntimePort,
13
15
  verifyPidIdentity,
14
- } from "../config";
16
+ } from "../config/process-state";
15
17
  import { isProcessAlive, killProxy } from "../lib/process-control";
16
18
  import { selfLaunchArgv } from "../lib/self-launch-argv";
17
19
  import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers";
@@ -176,7 +176,14 @@ export function transactionalNpmUpdate({
176
176
  }
177
177
  const spec = pkgName + "@" + (targetVersion || tag);
178
178
  log("Staging " + spec + " into " + stageRoot);
179
- const install = runNpm(["install", "-g", "--prefix", stageRoot, "--no-audit", "--no-fund", spec]);
179
+ // npm 12 blocks lifecycle scripts by default. Bun's postinstall copies the selected
180
+ // @oven/bun-* executable into bun/bin, so a successful npm exit without this narrow
181
+ // approval leaves the staged tree intentionally incomplete. Allow only the package
182
+ // whose executable the manifest verifies below; never broaden this to all scripts.
183
+ const install = runNpm([
184
+ "install", "-g", "--prefix", stageRoot,
185
+ "--allow-scripts=bun", "--no-audit", "--no-fund", spec,
186
+ ]);
180
187
  if (install.status !== 0) {
181
188
  try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
182
189
  return { ok: false, phase: "stage", error: "npm staging install failed (" + (install.status ?? "?") + ")" };
package/src/usage/log.ts CHANGED
@@ -931,6 +931,10 @@ async function readUsageEntriesIncrementally(
931
931
  // A shrink means truncation or replacement-in-place; the retained rows may no
932
932
  // longer correspond to file contents, so refuse to extend them.
933
933
  if (size < retained.coveredThroughBytes) return null;
934
+ // Retained-state reuse is only an optimization. A burst larger than the configured
935
+ // window must re-anchor through the bounded full-tail reader instead of reading and
936
+ // parsing every byte appended since the previous poll.
937
+ if (size - retained.coveredThroughBytes > maxReadBytes) return null;
934
938
  // Verify the retained REGION is unchanged before anything is reused. Identity keeps
935
939
  // dev/ino/birthtime, and an append and an in-place rewrite both move mtime/ctime
936
940
  // forward, so only the bytes themselves settle it.
@@ -1142,6 +1146,14 @@ export async function readUsageEntriesForManagement(): Promise<PersistedUsageEnt
1142
1146
  return (await readUsageSnapshotForManagement()).entries;
1143
1147
  }
1144
1148
 
1149
+ /** Keep legacy optional fields permissive, but reject rows that cannot be safely attributed. */
1150
+ function normalizePersistedUsageRow(value: unknown): PersistedUsageEntry | undefined {
1151
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
1152
+ const row = value as Record<string, unknown>;
1153
+ if (typeof row.requestId !== "string" || typeof row.provider !== "string") return undefined;
1154
+ return normalizeUsageEntry(row as unknown as PersistedUsageEntry);
1155
+ }
1156
+
1145
1157
  export function readUsageEntries(): PersistedUsageEntry[] {
1146
1158
  const path = usageLogPath();
1147
1159
  if (!existsSync(path)) return [];
@@ -1150,10 +1162,8 @@ export function readUsageEntries(): PersistedUsageEntry[] {
1150
1162
  for (const line of lines) {
1151
1163
  if (!line.trim()) continue;
1152
1164
  try {
1153
- const parsed = JSON.parse(line) as PersistedUsageEntry;
1154
- if (parsed && typeof parsed === "object" && typeof parsed.requestId === "string") {
1155
- entries.push(normalizeUsageEntry(parsed));
1156
- }
1165
+ const parsed = normalizePersistedUsageRow(JSON.parse(line));
1166
+ if (parsed) entries.push(parsed);
1157
1167
  } catch {
1158
1168
  /* keep reading after a partially written or hand-edited line */
1159
1169
  }
@@ -1166,10 +1176,8 @@ function parseUsageLines(lines: string[]): PersistedUsageEntry[] {
1166
1176
  for (const line of lines) {
1167
1177
  if (!line.trim()) continue;
1168
1178
  try {
1169
- const parsed = JSON.parse(line) as PersistedUsageEntry;
1170
- if (parsed && typeof parsed === "object" && typeof parsed.requestId === "string") {
1171
- entries.push(normalizeUsageEntry(parsed));
1172
- }
1179
+ const parsed = normalizePersistedUsageRow(JSON.parse(line));
1180
+ if (parsed) entries.push(parsed);
1173
1181
  } catch {
1174
1182
  /* skip partial / hand-edited lines */
1175
1183
  }
@@ -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
  }
@@ -751,3 +840,107 @@ export function summarizeUsage(
751
840
  accounts: buildAccounts(filteredEntries),
752
841
  };
753
842
  }
843
+
844
+ function normalizeFilterValue(input: string | null | undefined): string | null {
845
+ const trimmed = typeof input === "string" ? input.trim() : "";
846
+ return trimmed === "" ? null : trimmed.toLowerCase();
847
+ }
848
+
849
+ /**
850
+ * Narrow an already-summarised window to one provider and/or model.
851
+ *
852
+ * Deliberately a projection over a finished summary rather than a parameter to
853
+ * {@link summarizeUsage}. The management route caches summaries under
854
+ * `range:surface` and warms that key space as a cross-product; a filtered
855
+ * summary that reached either would be served to the next UNFILTERED caller,
856
+ * the dashboard included. Keeping the filter outside the producer makes that
857
+ * mistake unrepresentable rather than merely discouraged.
858
+ *
859
+ * Totals are recomputed from the retained rows. For combo traffic a request is
860
+ * counted once per participating model, so a filtered request count can exceed
861
+ * the number of distinct requests; `comboOverlap` reports when that is
862
+ * possible. Cost is unaffected — combo cost is attributed per attempt, so it
863
+ * partitions across models rather than repeating.
864
+ *
865
+ * `accounts` is emptied whenever a filter is active: account rows are not
866
+ * provider-partitioned in a way this projection could honestly re-derive, and
867
+ * unfiltered account totals sitting beside filtered model totals would invite
868
+ * exactly the wrong reading.
869
+ */
870
+ export function projectUsageSummary<T extends UsageSummary>(
871
+ summary: T,
872
+ filter: { provider?: string | null; model?: string | null },
873
+ entries?: PersistedUsageEntry[],
874
+ ): T & { filter?: UsageFilterEcho } {
875
+ const provider = normalizeFilterValue(filter.provider);
876
+ const model = normalizeFilterValue(filter.model);
877
+ if (provider === null && model === null) return summary;
878
+
879
+ // Re-summarise from the entries the summary was built from, rather than
880
+ // projecting over its rows.
881
+ //
882
+ // Projecting rows looked cheaper and was wrong in three ways that only show
883
+ // up together: breakdown rows past MAX_USAGE_MODEL_BREAKDOWN_ROWS are
884
+ // collapsed into a synthetic "other" row, so a provider living only in that
885
+ // tail is unfindable and reports matched:false despite real usage; a
886
+ // provider row is a whole-provider aggregate, so a model filter kept the
887
+ // provider's OTHER models in providers[] while models[] and the totals
888
+ // excluded them, contradicting itself inside one response; and a model row
889
+ // carries a single optional cost, so priced/unpriced/unmetered counts could
890
+ // only be guessed per model rather than counted per request.
891
+ //
892
+ // The entries are already in hand on every path that filters, so the honest
893
+ // computation is also the simple one.
894
+ const matches = (rowProvider: string, rowModel: string): boolean => {
895
+ if (provider !== null && baseProviderLabel(rowProvider).toLowerCase() !== provider) return false;
896
+ if (model !== null && rowModel.toLowerCase() !== model) return false;
897
+ return true;
898
+ };
899
+
900
+ // Narrow to matching ATTRIBUTIONS, not matching entries.
901
+ //
902
+ // Keeping a whole combo entry because one of its attempts matched drags the
903
+ // other attempts' tokens and cost into the filtered totals: a two-attempt
904
+ // combo filtered to its cheap model reported the expensive model's spend
905
+ // too. Rewriting the entry down to its matching attempts is what makes the
906
+ // filtered numbers mean what the flag says.
907
+ const source = entries ?? [];
908
+ let comboOverlap = false;
909
+ const filtered: PersistedUsageEntry[] = [];
910
+ for (const entry of source) {
911
+ if (!entry.attempts?.length) {
912
+ if (matches(entry.provider, antigravityUsageModel(entry.provider, entry.model))) filtered.push(entry);
913
+ continue;
914
+ }
915
+ const attempts = entry.attempts.filter(a => matches(a.provider, antigravityUsageModel(a.provider, a.model)));
916
+ if (attempts.length === 0) continue;
917
+ // A combo is still counted once per participating model, so a filtered
918
+ // request count can exceed the number of distinct requests. That is the
919
+ // documented overlap, and it is why comboOverlap exists.
920
+ if (entry.attempts.length > 1) comboOverlap = true;
921
+ filtered.push({ ...entry, attempts });
922
+ }
923
+
924
+ const projected = summarizeUsage(filtered, summary.range, summary.generatedAt, summary.surface);
925
+ // matched reflects usage inside the requested WINDOW, not anywhere in the
926
+ // log: summarizeUsage applies the range and surface predicates, and the CLI
927
+ // uses this flag to decide between a table and "no usage recorded".
928
+ const matched = projected.summary.requests > 0;
929
+ // A combo entry survives the entry filter as a whole, so its non-matching
930
+ // attributions can still appear as rows. Drop those so every row in the
931
+ // response satisfies the filter the caller asked for.
932
+ const models = projected.models.filter(row => matches(row.provider, row.model));
933
+ const retainedProviders = new Set(models.map(row => row.provider));
934
+ return {
935
+ ...summary,
936
+ summary: projected.summary,
937
+ days: projected.days.map(day => ({ ...day, models: day.models.filter(row => matches(row.provider, row.model)) })),
938
+ models,
939
+ providers: projected.providers.filter(row => retainedProviders.has(row.provider)),
940
+ // Account rows are not provider-partitioned in a way this projection could
941
+ // honestly re-derive, and unfiltered account totals sitting beside filtered
942
+ // model totals would invite exactly the wrong reading.
943
+ accounts: [],
944
+ filter: { provider, model, matched, comboOverlap },
945
+ };
946
+ }
@@ -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 {
@@ -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 {
@@ -19,6 +19,7 @@ import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
19
19
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
20
20
  import { getProviderRegistryEntry } from "../providers/registry";
21
21
  import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse";
22
+ import { appendSafeWebSearchSource } from "./sources";
22
23
  import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor";
23
24
 
24
25
  const CCA_FALLBACK_BASE = "https://daily-cloudcode-pa.googleapis.com";
@@ -117,25 +118,46 @@ export async function runGeminiWebSearch(
117
118
  }
118
119
 
119
120
  /** Map a CCA generateContent payload (possibly wrapped in {response}) to text + grounding sources. */
121
+ export function extractCcaGroundingSources(groundingMetadata: unknown): WebSearchSource[] {
122
+ const sources: WebSearchSource[] = [];
123
+ const gm = isRec(groundingMetadata) ? groundingMetadata : undefined;
124
+ if (!gm || !Array.isArray(gm.groundingChunks)) return sources;
125
+ for (const chunk of gm.groundingChunks) {
126
+ const web = isRec(chunk) && isRec(chunk.web) ? chunk.web : undefined;
127
+ if (!web) continue;
128
+ // Route through the shared safe-source validator: enforces http(s) scheme,
129
+ // control-character filtering, dedup, and per-message count/byte budgets.
130
+ appendSafeWebSearchSource(sources, { url: web.uri, title: web.title });
131
+ }
132
+ return sources;
133
+ }
134
+
135
+ /** Skip search-suggestion HTML widgets Google may attach alongside grounded answers. */
136
+ export function isCcaSearchSuggestionHtml(text: string): boolean {
137
+ const trimmed = text.trim();
138
+ if (!trimmed.startsWith("<")) return false;
139
+ return /<style[\s>]/i.test(trimmed) || /search[_-]?suggest/i.test(trimmed) || /grounding-widget/i.test(trimmed);
140
+ }
141
+
142
+ export function formatCcaGroundingSourcesAppendix(sources: readonly WebSearchSource[]): string {
143
+ if (sources.length === 0) return "";
144
+ const lines = ["", "Sources:"];
145
+ sources.slice(0, 8).forEach((s, i) => {
146
+ lines.push(`[${i + 1}] ${s.title ? `${s.title} — ` : ""}${s.url}`);
147
+ });
148
+ return lines.join("\n");
149
+ }
150
+
120
151
  export function mapCcaGroundedResponse(payload: unknown): SidecarOutcome {
121
152
  const root = isRec(payload) && isRec(payload.response) ? payload.response : payload;
122
153
  if (!isRec(root)) return { text: "", sources: [], error: "gemini sidecar returned a non-JSON or empty body" };
123
154
  const candidate = Array.isArray(root.candidates) && isRec(root.candidates[0]) ? root.candidates[0] : undefined;
124
155
  if (!candidate) return { text: "", sources: [], error: "gemini sidecar returned no candidates" };
125
156
  const parts = isRec(candidate.content) && Array.isArray(candidate.content.parts) ? candidate.content.parts : [];
126
- const text = parts.map(p => (isRec(p) && typeof p.text === "string" ? p.text : "")).join("");
127
- const sources: WebSearchSource[] = [];
128
- const seen = new Set<string>();
129
- const gm = isRec(candidate.groundingMetadata) ? candidate.groundingMetadata : undefined;
130
- if (gm && Array.isArray(gm.groundingChunks)) {
131
- for (const chunk of gm.groundingChunks) {
132
- const web = isRec(chunk) && isRec(chunk.web) ? chunk.web : undefined;
133
- const uri = web && typeof web.uri === "string" ? web.uri : undefined;
134
- if (!uri || seen.has(uri)) continue;
135
- seen.add(uri);
136
- sources.push({ url: uri, ...(typeof web?.title === "string" && web.title.length > 0 ? { title: web.title } : {}) });
137
- }
138
- }
157
+ const text = parts
158
+ .map(p => (isRec(p) && typeof p.text === "string" && !isCcaSearchSuggestionHtml(p.text) ? p.text : ""))
159
+ .join("");
160
+ const sources = extractCcaGroundingSources(candidate.groundingMetadata);
139
161
  if (text.length === 0) return { text: "", sources, error: "gemini sidecar returned no text" };
140
162
  return { text, sources };
141
163
  }