@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/usage/log.ts CHANGED
@@ -44,6 +44,12 @@ export interface PersistedUsageEntry {
44
44
  provider: string;
45
45
  model: string;
46
46
  surface?: "claude" | "claude-desktop" | "grok";
47
+ /** Matched configured key id; absent for environment/loopback admissions and
48
+ * for every row written before attribution existed. */
49
+ apiKeyId?: string;
50
+ admissionKind?: "configured" | "environment" | "loopback";
51
+ /** The inbound wire, not the client product — see `surface`. */
52
+ inboundProtocol?: "responses" | "chat" | "messages";
47
53
  /** Best-effort chat/session correlation for Logs grouping (#330). */
48
54
  conversationId?: string;
49
55
  resolvedModel?: string;
@@ -94,6 +100,24 @@ export function isKnownUsageSurface(value: unknown): value is NonNullable<Persis
94
100
  return typeof value === "string" && KNOWN_USAGE_SURFACES.has(value as NonNullable<PersistedUsageEntry["surface"]>);
95
101
  }
96
102
 
103
+ const KNOWN_ADMISSION_KINDS = new Set<NonNullable<PersistedUsageEntry["admissionKind"]>>([
104
+ "configured", "environment", "loopback",
105
+ ]);
106
+
107
+ const KNOWN_INBOUND_PROTOCOLS = new Set<NonNullable<PersistedUsageEntry["inboundProtocol"]>>([
108
+ "responses", "chat", "messages",
109
+ ]);
110
+
111
+ /** Same closed-set discipline as `isKnownUsageSurface`: an old or corrupted row
112
+ * carrying an unexpected value drops the field instead of poisoning the enum. */
113
+ export function isKnownAdmissionKind(value: unknown): value is NonNullable<PersistedUsageEntry["admissionKind"]> {
114
+ return typeof value === "string" && KNOWN_ADMISSION_KINDS.has(value as NonNullable<PersistedUsageEntry["admissionKind"]>);
115
+ }
116
+
117
+ export function isKnownInboundProtocol(value: unknown): value is NonNullable<PersistedUsageEntry["inboundProtocol"]> {
118
+ return typeof value === "string" && KNOWN_INBOUND_PROTOCOLS.has(value as NonNullable<PersistedUsageEntry["inboundProtocol"]>);
119
+ }
120
+
97
121
  export function usageLogPath(): string {
98
122
  return join(getConfigDir(), "usage.jsonl");
99
123
  }
@@ -263,6 +287,11 @@ function capMetadataString(s: string): string {
263
287
  return s.length > MAX_METADATA_STRING_LEN ? s.slice(0, MAX_METADATA_STRING_LEN) : s;
264
288
  }
265
289
 
290
+ /** Test seam: the normalization branch old rows take is worth asserting directly. */
291
+ export function normalizeUsageEntryForTest(entry: PersistedUsageEntry): PersistedUsageEntry {
292
+ return normalizeUsageEntry(entry);
293
+ }
294
+
266
295
  function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
267
296
  const attempts = normalizedAttempts(entry.attempts);
268
297
  return {
@@ -271,6 +300,15 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
271
300
  provider: entry.provider,
272
301
  model: entry.model,
273
302
  ...(isKnownUsageSurface(entry.surface) ? { surface: entry.surface } : {}),
303
+ ...(typeof entry.apiKeyId === "string" && entry.apiKeyId.trim()
304
+ // Deliberately NOT capped. `capMetadataString` protects free-form metadata
305
+ // from unbounded growth, but this is a lookup key: truncating it makes the
306
+ // persisted id stop matching the configured one, and the rollup silently
307
+ // reports zero for a key that is very much in use.
308
+ ? { apiKeyId: entry.apiKeyId }
309
+ : {}),
310
+ ...(isKnownAdmissionKind(entry.admissionKind) ? { admissionKind: entry.admissionKind } : {}),
311
+ ...(isKnownInboundProtocol(entry.inboundProtocol) ? { inboundProtocol: entry.inboundProtocol } : {}),
274
312
  ...(typeof entry.conversationId === "string" && entry.conversationId.trim()
275
313
  ? { conversationId: entry.conversationId.trim().slice(0, 128) }
276
314
  : {}),
@@ -349,9 +387,22 @@ export type UsageLogRevision = {
349
387
  };
350
388
 
351
389
  let usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
390
+ const MANAGEMENT_USAGE_MAX_READ_BYTES = 64 * 1024 * 1024;
391
+ const MANAGEMENT_USAGE_READ_CHUNK_BYTES = 1024 * 1024;
392
+ const MANAGEMENT_USAGE_MAX_ENTRIES = 200_000;
393
+ const MANAGEMENT_USAGE_FLIGHT_STALE_MS = 30_000;
394
+ export interface ManagementUsageSnapshot {
395
+ entries: PersistedUsageEntry[];
396
+ revision: UsageLogRevision;
397
+ truncatedPrefixBytes: number;
398
+ entriesTruncated: boolean;
399
+ entriesDropped: number;
400
+ }
352
401
  let managementUsageReadInflight: {
353
402
  key: string;
354
- promise: Promise<{ entries: PersistedUsageEntry[]; revision: UsageLogRevision }>;
403
+ promise: Promise<ManagementUsageSnapshot>;
404
+ startedAt: number;
405
+ abort: AbortController;
355
406
  } | null = null;
356
407
 
357
408
  /** Test-only observability for proving that unchanged prefixes are not reparsed. */
@@ -361,6 +412,7 @@ export function usageReadCacheStatsForTests(): Readonly<typeof usageReadCacheSta
361
412
 
362
413
  export function resetUsageReadCacheForTests(): void {
363
414
  usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
415
+ managementUsageReadInflight?.abort.abort();
364
416
  managementUsageReadInflight = null;
365
417
  }
366
418
 
@@ -413,12 +465,16 @@ export function currentUsageLogRevision(): UsageLogRevision | null {
413
465
  }
414
466
  }
415
467
 
416
- async function parseUsageTextCooperatively(text: string): Promise<PersistedUsageEntry[]> {
468
+ async function parseUsageTextCooperatively(text: string, signal: AbortSignal): Promise<{
469
+ entries: PersistedUsageEntry[];
470
+ entriesDropped: number;
471
+ }> {
417
472
  const lines = text.split(/\r?\n/);
418
473
  usageReadCacheStats.parsedLines += lines.filter(line => line.trim()).length;
419
474
  const entries: PersistedUsageEntry[] = [];
420
475
  const batchSize = 1_000;
421
476
  for (let offset = 0; offset < lines.length; offset += batchSize) {
477
+ if (signal.aborted) throw signal.reason;
422
478
  entries.push(...parseUsageLines(lines.slice(offset, offset + batchSize)));
423
479
  if (offset + batchSize < lines.length) {
424
480
  // JSON parsing dominates large-log startup. Yield between bounded batches so
@@ -426,22 +482,56 @@ async function parseUsageTextCooperatively(text: string): Promise<PersistedUsage
426
482
  await new Promise<void>(resolve => setTimeout(resolve, 0));
427
483
  }
428
484
  }
429
- return entries;
485
+ if (entries.length <= MANAGEMENT_USAGE_MAX_ENTRIES) return { entries, entriesDropped: 0 };
486
+ const entriesDropped = entries.length - MANAGEMENT_USAGE_MAX_ENTRIES;
487
+ return { entries: entries.slice(-MANAGEMENT_USAGE_MAX_ENTRIES), entriesDropped };
430
488
  }
431
489
 
432
490
  async function readUsageEntriesFullCooperatively(
433
491
  path: string,
434
- ): Promise<{ entries: PersistedUsageEntry[]; revision: UsageLogRevision }> {
492
+ signal: AbortSignal,
493
+ maxReadBytes: number,
494
+ ): Promise<ManagementUsageSnapshot> {
435
495
  let fd: number | undefined;
436
496
  try {
437
497
  fd = openSync(path, "r");
438
498
  const stat = fstatSync(fd);
439
499
  const size = Number(stat.size);
440
- const bytes = readExactly(fd, size, 0);
441
- if (bytes === null) throw new Error("usage log changed while it was being read");
442
- const entries = await parseUsageTextCooperatively(bytes.toString("utf-8"));
500
+ const start = Math.max(0, size - maxReadBytes);
501
+ const chunks: Buffer[] = [];
502
+ for (let position = start; position < size;) {
503
+ if (signal.aborted) throw signal.reason;
504
+ const length = Math.min(MANAGEMENT_USAGE_READ_CHUNK_BYTES, size - position);
505
+ const chunk = readExactly(fd, length, position);
506
+ if (chunk === null) throw new Error("usage log changed while it was being read");
507
+ chunks.push(chunk);
508
+ position += length;
509
+ }
510
+ let bytes = Buffer.concat(chunks);
511
+ let truncatedPrefixBytes = start;
512
+ if (start > 0) {
513
+ const preceding = readExactly(fd, 1, start - 1);
514
+ if (preceding === null) throw new Error("usage log changed while it was being read");
515
+ if (preceding[0] !== 0x0a) {
516
+ const newline = bytes.indexOf(0x0a);
517
+ if (newline < 0) {
518
+ truncatedPrefixBytes += bytes.byteLength;
519
+ bytes = Buffer.alloc(0);
520
+ } else {
521
+ truncatedPrefixBytes += newline + 1;
522
+ bytes = bytes.subarray(newline + 1);
523
+ }
524
+ }
525
+ }
526
+ const parsed = await parseUsageTextCooperatively(bytes.toString("utf-8"), signal);
443
527
  usageReadCacheStats.fullReads += 1;
444
- return { entries, revision: usageLogRevision(path, stat) };
528
+ return {
529
+ entries: parsed.entries,
530
+ revision: usageLogRevision(path, stat),
531
+ truncatedPrefixBytes,
532
+ entriesTruncated: parsed.entriesDropped > 0,
533
+ entriesDropped: parsed.entriesDropped,
534
+ };
445
535
  } finally {
446
536
  if (fd !== undefined) closeSync(fd);
447
537
  }
@@ -452,22 +542,30 @@ async function readUsageEntriesFullCooperatively(
452
542
  * callers share work only when they observed the same exact file revision. Parsed rows
453
543
  * are returned to the request and never retained in module state.
454
544
  */
455
- export async function readUsageSnapshotForManagement(): Promise<{
545
+ export async function readUsageSnapshotForManagement(maxReadBytes = MANAGEMENT_USAGE_MAX_READ_BYTES): Promise<{
456
546
  entries: PersistedUsageEntry[];
457
547
  revision: UsageLogRevision | null;
548
+ truncatedPrefixBytes: number;
549
+ entriesTruncated: boolean;
550
+ entriesDropped: number;
458
551
  }> {
552
+ if (!Number.isSafeInteger(maxReadBytes) || maxReadBytes <= 0) throw new RangeError("management usage max read bytes must be positive");
459
553
  const path = usageLogPath();
460
- if (!existsSync(path)) return { entries: [], revision: null };
554
+ if (!existsSync(path)) return { entries: [], revision: null, truncatedPrefixBytes: 0, entriesTruncated: false, entriesDropped: 0 };
461
555
  const observed = currentUsageLogRevision();
462
- const key = usageLogRevisionKey(observed);
463
- if (managementUsageReadInflight?.key === key) {
464
- const shared = await managementUsageReadInflight.promise;
465
- return { entries: shared.entries.slice(), revision: shared.revision };
556
+ const key = `${usageLogRevisionKey(observed)}\0${maxReadBytes}`;
557
+ const existing = managementUsageReadInflight;
558
+ if (existing?.key === key && Date.now() - existing.startedAt <= MANAGEMENT_USAGE_FLIGHT_STALE_MS) {
559
+ const shared = await existing.promise;
560
+ return { ...shared, entries: shared.entries.slice() };
466
561
  }
467
- const promise = readUsageEntriesFullCooperatively(path);
468
- managementUsageReadInflight = { key, promise };
562
+ existing?.abort.abort(new Error("management usage read superseded"));
563
+ const abort = new AbortController();
564
+ const promise = readUsageEntriesFullCooperatively(path, abort.signal, maxReadBytes);
565
+ managementUsageReadInflight = { key, promise, startedAt: Date.now(), abort };
469
566
  try {
470
- return await promise;
567
+ const snapshot = await promise;
568
+ return { ...snapshot, entries: snapshot.entries.slice() };
471
569
  } finally {
472
570
  if (managementUsageReadInflight?.promise === promise) managementUsageReadInflight = null;
473
571
  }
@@ -91,6 +91,17 @@ export interface UsageSummary {
91
91
  }
92
92
 
93
93
  const DAY_MS = 86_400_000;
94
+ export const MAX_USAGE_MODEL_BREAKDOWN_ROWS = 256;
95
+
96
+ function retainedBreakdownRows<T>(
97
+ rows: T[],
98
+ aggregateOverflow: (overflow: T[]) => T,
99
+ ): T[] {
100
+ if (rows.length <= MAX_USAGE_MODEL_BREAKDOWN_ROWS) return rows;
101
+ const keep = rows.slice(0, MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1);
102
+ keep.push(aggregateOverflow(rows.slice(MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1)));
103
+ return keep;
104
+ }
94
105
 
95
106
  export function parseRange(input: string | null | undefined): UsageRange {
96
107
  if (input === "7d" || input === "30d" || input === "all") return input;
@@ -334,7 +345,21 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr
334
345
  const out = [...grid.values()].sort((a, b) => a.date.localeCompare(b.date));
335
346
  for (const day of out) {
336
347
  const models = dayModels.get(day.date);
337
- if (models) day.models = [...models.values()].sort((a, b) => b.requests - a.requests);
348
+ if (models) {
349
+ const sorted = [...models.values()].sort((a, b) => b.requests - a.requests);
350
+ day.models = retainedBreakdownRows(sorted, overflow => {
351
+ const requests = new Set<string>();
352
+ let attemptCount = 0;
353
+ let totalTokens = 0;
354
+ for (const model of overflow) {
355
+ attemptCount += model.attemptCount;
356
+ totalTokens += model.totalTokens;
357
+ const requestKey = `${day.date}\0${usageModelKey(model.provider, model.model)}`;
358
+ for (const requestId of dayModelRequests.get(requestKey) ?? []) requests.add(requestId);
359
+ }
360
+ return { model: "other", provider: "other", requests: requests.size, attemptCount, totalTokens };
361
+ });
362
+ }
338
363
  }
339
364
  return out;
340
365
  }
@@ -414,7 +439,47 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage
414
439
  }
415
440
  const models = [...byKey.values()];
416
441
  for (const m of models) m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens;
417
- return models.sort((a, b) => b.requests - a.requests);
442
+ const sorted = models.sort((a, b) => b.requests - a.requests);
443
+ return retainedBreakdownRows(sorted, overflow => {
444
+ const statusesByRequest = new Map<string, UsageStatus[]>();
445
+ const other: UsageModel = {
446
+ provider: "other",
447
+ model: "other",
448
+ requests: 0,
449
+ attemptCount: 0,
450
+ measuredRequests: 0,
451
+ reportedRequests: 0,
452
+ estimatedRequests: 0,
453
+ totalTokens: 0,
454
+ inputTokens: 0,
455
+ outputTokens: 0,
456
+ shareRatio: 0,
457
+ };
458
+ for (const model of overflow) {
459
+ other.attemptCount += model.attemptCount;
460
+ other.totalTokens += model.totalTokens;
461
+ other.inputTokens += model.inputTokens;
462
+ other.outputTokens += model.outputTokens;
463
+ if (model.estimatedCostUsd !== undefined) {
464
+ other.estimatedCostUsd = (other.estimatedCostUsd ?? 0) + model.estimatedCostUsd;
465
+ }
466
+ const key = usageModelKey(model.provider, model.model);
467
+ for (const [requestId, statuses] of statusesByKey.get(key) ?? []) {
468
+ const combined = statusesByRequest.get(requestId) ?? [];
469
+ combined.push(...statuses);
470
+ statusesByRequest.set(requestId, combined);
471
+ }
472
+ }
473
+ other.requests = statusesByRequest.size;
474
+ for (const statuses of statusesByRequest.values()) {
475
+ const status = foldAttributionStatuses(statuses);
476
+ if (isMeasuredStatus(status)) other.measuredRequests += 1;
477
+ if (status === "reported") other.reportedRequests += 1;
478
+ else if (status === "estimated") other.estimatedRequests += 1;
479
+ }
480
+ other.shareRatio = totalTokens === 0 ? 0 : other.totalTokens / totalTokens;
481
+ return other;
482
+ });
418
483
  }
419
484
 
420
485
  function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): UsageProvider[] {
@@ -7,6 +7,8 @@ import type { CodexAuthContext } from "../codex/auth-context";
7
7
  import { getAccountSet } from "../oauth/store";
8
8
  import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
9
9
  import type { SidecarOutcomeRecorder } from "../web-search/executor";
10
+ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
11
+ import type { TranslatorBudget } from "../lib/translator-budget";
10
12
 
11
13
  export { describeImage } from "./describe";
12
14
  export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe";
@@ -16,6 +18,8 @@ const DEFAULT_ANTHROPIC_VISION_MODEL = "claude-sonnet-5";
16
18
  const DEFAULT_TIMEOUT_MS = 45_000;
17
19
  const DEFAULT_MAX_DESCRIPTIONS_PER_TURN = 8;
18
20
  const DESCRIPTION_CACHE_MAX_ENTRIES = 256;
21
+ export const VISION_DESCRIPTION_CACHE_MAX_BYTES = 1024 * 1024;
22
+ const descriptionEncoder = new TextEncoder();
19
23
  /** Max images described in parallel — keeps first-token latency bounded without flooding the backend. */
20
24
  const VISION_CONCURRENCY = 3;
21
25
  /** Per-image description hard cap (chars) so multi-image turns can't blow the main model's context. */
@@ -27,47 +31,109 @@ export interface VisionDescriptionCache {
27
31
  get(key: string): string | undefined;
28
32
  set(key: string, value: string): void;
29
33
  clear(): void;
34
+ snapshot?(): { count: number; bytes: number; oldestAt: number | null };
35
+ evictOldest?(): number;
30
36
  }
31
37
 
32
38
  class BoundedLruDescriptionCache implements VisionDescriptionCache {
33
- private readonly entries = new Map<string, string>();
39
+ private readonly entries = new Map<string, { value: string; sizeBytes: number; storedAt: number }>();
40
+ private bytes = 0;
34
41
 
35
- constructor(private readonly maxEntries: number) {}
42
+ constructor(private readonly maxEntries: number, private readonly maxBytes: number) {}
36
43
 
37
44
  get(key: string): string | undefined {
38
- const value = this.entries.get(key);
39
- if (value === undefined) return undefined;
45
+ const entry = this.entries.get(key);
46
+ if (entry === undefined) return undefined;
40
47
  this.entries.delete(key);
41
- this.entries.set(key, value);
42
- return value;
48
+ entry.storedAt = Date.now();
49
+ this.entries.set(key, entry);
50
+ return entry.value;
43
51
  }
44
52
 
45
53
  set(key: string, value: string): void {
46
- this.entries.delete(key);
47
- this.entries.set(key, value);
48
- while (this.entries.size > this.maxEntries) {
49
- const oldest = this.entries.keys().next().value;
50
- if (oldest === undefined) break;
51
- this.entries.delete(oldest);
54
+ const existing = this.entries.get(key);
55
+ if (existing) {
56
+ this.entries.delete(key);
57
+ this.bytes -= existing.sizeBytes;
58
+ }
59
+ const sizeBytes = descriptionEncoder.encode(key).byteLength + descriptionEncoder.encode(value).byteLength;
60
+ if (sizeBytes > this.maxBytes || this.maxEntries <= 0) return;
61
+ while (this.entries.size + 1 > this.maxEntries || this.bytes + sizeBytes > this.maxBytes) {
62
+ if (this.evictOldest() === 0) return;
52
63
  }
64
+ this.entries.set(key, { value, sizeBytes, storedAt: Date.now() });
65
+ this.bytes += sizeBytes;
53
66
  }
54
67
 
55
68
  clear(): void {
56
69
  this.entries.clear();
70
+ this.bytes = 0;
71
+ }
72
+
73
+ snapshot(): { count: number; bytes: number; oldestAt: number | null } {
74
+ return {
75
+ count: this.entries.size,
76
+ bytes: this.bytes,
77
+ oldestAt: this.entries.values().next().value?.storedAt ?? null,
78
+ };
79
+ }
80
+
81
+ evictOldest(): number {
82
+ const oldest = this.entries.keys().next().value;
83
+ if (oldest === undefined) return 0;
84
+ const entry = this.entries.get(oldest);
85
+ if (!entry) return 0;
86
+ this.entries.delete(oldest);
87
+ this.bytes -= entry.sizeBytes;
88
+ return entry.sizeBytes;
57
89
  }
58
90
  }
59
91
 
60
- let descriptionCache: VisionDescriptionCache = new BoundedLruDescriptionCache(DESCRIPTION_CACHE_MAX_ENTRIES);
92
+ let descriptionCacheLimits = {
93
+ maxEntries: DESCRIPTION_CACHE_MAX_ENTRIES,
94
+ maxBytes: VISION_DESCRIPTION_CACHE_MAX_BYTES,
95
+ };
96
+
97
+ function defaultDescriptionCache(): VisionDescriptionCache {
98
+ return new BoundedLruDescriptionCache(descriptionCacheLimits.maxEntries, descriptionCacheLimits.maxBytes);
99
+ }
100
+
101
+ let descriptionCache: VisionDescriptionCache = defaultDescriptionCache();
61
102
 
62
103
  /** Replace the process cache (primarily for deterministic tests). Passing undefined restores the default LRU. */
63
104
  export function setVisionDescriptionCache(cache?: VisionDescriptionCache): void {
64
- descriptionCache = cache ?? new BoundedLruDescriptionCache(DESCRIPTION_CACHE_MAX_ENTRIES);
105
+ descriptionCache = cache ?? defaultDescriptionCache();
65
106
  }
66
107
 
67
108
  export function resetVisionDescriptionCache(): void {
68
109
  descriptionCache.clear();
69
110
  }
70
111
 
112
+ export function setVisionDescriptionCacheLimitsForTests(
113
+ limits?: { maxEntries?: number; maxBytes?: number },
114
+ ): void {
115
+ descriptionCacheLimits = limits
116
+ ? { maxEntries: limits.maxEntries ?? DESCRIPTION_CACHE_MAX_ENTRIES, maxBytes: limits.maxBytes ?? VISION_DESCRIPTION_CACHE_MAX_BYTES }
117
+ : { maxEntries: DESCRIPTION_CACHE_MAX_ENTRIES, maxBytes: VISION_DESCRIPTION_CACHE_MAX_BYTES };
118
+ descriptionCache = defaultDescriptionCache();
119
+ }
120
+
121
+ export function visionDescriptionRetainedStoreSnapshot(): {
122
+ count: number;
123
+ bytes: number;
124
+ evictableBytes: number;
125
+ pinnedBytes: number;
126
+ oldestAt: number | null;
127
+ } {
128
+ const snapshot = descriptionCache.snapshot?.();
129
+ if (!snapshot) return { count: 0, bytes: 0, evictableBytes: 0, pinnedBytes: 0, oldestAt: null };
130
+ return { ...snapshot, evictableBytes: snapshot.bytes, pinnedBytes: 0 };
131
+ }
132
+
133
+ export function evictOldestVisionDescriptionForBudget(): number {
134
+ return descriptionCache.evictOldest?.() ?? 0;
135
+ }
136
+
71
137
  /** Runtime config is permissive: zero is intentional; malformed values fall back to the bounded default. */
72
138
  export function resolveMaxDescriptionsPerTurn(value: unknown): number {
73
139
  if (value === 0) return 0;
@@ -279,6 +345,7 @@ export async function describeImagesInPlace(
279
345
  selectedForwardHeaders: Headers,
280
346
  abortSignal?: AbortSignal,
281
347
  recordSidecarOutcome?: SidecarOutcomeRecorder,
348
+ translatorBudget?: TranslatorBudget,
282
349
  ): Promise<void> {
283
350
  // 1. Gather every image part across messages, each with its own message's text as context.
284
351
  const jobs: ImageJob[] = [];
@@ -338,9 +405,13 @@ export async function describeImagesInPlace(
338
405
  } catch (error) {
339
406
  outcome = { text: "", error: error instanceof Error ? error.message : String(error) };
340
407
  }
341
- const successfulText = outcome.error ? "" : outcome.text.trim();
342
- if (identity.persistent && successfulText) descriptionCache.set(identity.key, successfulText);
343
- resolveOutcome(outcome);
408
+ const successfulText = outcome.error ? "" : clamp(outcome.text.trim(), DESC_MAX_CHARS);
409
+ if (identity.persistent && successfulText) {
410
+ descriptionCache.set(identity.key, successfulText);
411
+ enforceAppOwnedMemoryBudget();
412
+ }
413
+ const resolvedOutcome = outcome.error ? outcome : { ...outcome, text: successfulText };
414
+ resolveOutcome(resolvedOutcome);
344
415
  });
345
416
  }
346
417
 
@@ -351,7 +422,19 @@ export async function describeImagesInPlace(
351
422
  let oi = 0;
352
423
  for (const { msg, parts } of targets) {
353
424
  const newParts: OcxContentPart[] = [];
354
- for (const p of parts) newParts.push(p.type === "image" ? renderDescription(outcomes[oi++]) : p);
425
+ for (const p of parts) {
426
+ if (p.type !== "image") {
427
+ newParts.push(p);
428
+ continue;
429
+ }
430
+ const replacement = renderDescription(outcomes[oi++]);
431
+ const reservation = translatorBudget?.reserveTransient(
432
+ descriptionEncoder.encode(replacement.text).byteLength,
433
+ { kind: "request_copies" },
434
+ );
435
+ newParts.push(replacement);
436
+ reservation?.commitRetained();
437
+ }
355
438
  msg.content = newParts;
356
439
  }
357
440
  }
@@ -362,15 +445,22 @@ export async function describeImagesInPlace(
362
445
  * raw images would 400 or silently confuse it. Replace each image with an explicit marker so the
363
446
  * model (and the user, via its reply) knows the image was dropped rather than ignored.
364
447
  */
365
- export function stripImagesInPlace(parsed: OcxParsedRequest): boolean {
448
+ export function stripImagesInPlace(parsed: OcxParsedRequest, translatorBudget?: TranslatorBudget): boolean {
366
449
  let stripped = false;
367
450
  for (const msg of parsed.context.messages) {
368
451
  if (!carriesImages(msg.role) || !Array.isArray(msg.content)) continue;
369
452
  const parts = msg.content as OcxContentPart[];
370
453
  if (!parts.some(p => p.type === "image")) continue;
371
- msg.content = parts.map(p => p.type === "image"
372
- ? { type: "text", text: "[image omitted: this model is text-only and the vision sidecar is unavailable (no ChatGPT login)]" } as OcxContentPart
373
- : p);
454
+ msg.content = parts.map(p => {
455
+ if (p.type !== "image") return p;
456
+ const replacement = { type: "text", text: "[image omitted: this model is text-only and the vision sidecar is unavailable (no ChatGPT login)]" } as OcxContentPart;
457
+ const reservation = translatorBudget?.reserveTransient(
458
+ descriptionEncoder.encode((replacement as OcxTextContent).text).byteLength,
459
+ { kind: "request_copies" },
460
+ );
461
+ reservation?.commitRetained();
462
+ return replacement;
463
+ });
374
464
  stripped = true;
375
465
  }
376
466
  return stripped;
@@ -1,4 +1,4 @@
1
- import type { AdapterRequest, ProviderAdapter } from "../adapters/base";
1
+ import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base";
2
2
  import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage } from "../types";
3
3
  import { namespacedToolName } from "../types";
4
4
  import { bridgeToResponsesSSE } from "../bridge";
@@ -8,6 +8,11 @@ import { clearableDeadline } from "../lib/abort";
8
8
  import { redactSecretString } from "../lib/redact";
9
9
  import { readBoundedResponseBody } from "../lib/bounded-body";
10
10
  import { fetchWithResetRetry } from "../lib/upstream-retry";
11
+ import {
12
+ isTranslatorBudgetExceededError,
13
+ TRANSLATOR_MAX_TURN_BYTES,
14
+ TranslatorBudgetExceededError,
15
+ } from "../lib/translator-budget";
11
16
  import { formatWebSearchResults } from "./format-result";
12
17
  import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "./progress-stream";
13
18
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
@@ -204,6 +209,7 @@ class LoopError extends Error {
204
209
  export interface WebSearchLoopDeps {
205
210
  parsed: OcxParsedRequest;
206
211
  adapter: ProviderAdapter;
212
+ incomingMeta: IncomingMeta;
207
213
  /** Which executor runs searches. Defaults to "openai" so existing callers keep the ChatGPT path (audit F4). */
208
214
  backend?: "openai" | "anthropic";
209
215
  /** Required for the openai backend; unused (and typically undefined) for the anthropic backend. */
@@ -247,6 +253,7 @@ export interface WebSearchLoopDeps {
247
253
  * inject the answer as a tool_result, and loop (bounded by `maxSearches`).
248
254
  */
249
255
  export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Response> {
256
+ const translatorBudget = deps.incomingMeta.translatorBudget;
250
257
  const { parsed, selectedForwardHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal, recordSidecarOutcome } = deps;
251
258
  const backend = deps.backend ?? "openai";
252
259
  const anthropicSidecar = deps.anthropicSidecar;
@@ -312,14 +319,17 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
312
319
  const request = await requestAdapter.buildRequest(iterParsed, {
313
320
  headers: selectedForwardHeaders,
314
321
  abortSignal: headerDeadline.signal,
322
+ translatorBudget,
315
323
  });
316
324
  try {
317
325
  deps.onRequestBuilt?.(request);
318
326
  } catch {
319
327
  // Diagnostics are best-effort and must never abort a web-search iteration.
320
328
  }
321
- const response = requestAdapter.fetchResponse
322
- ? await requestAdapter.fetchResponse(request, {
329
+ let response: Response;
330
+ try {
331
+ response = requestAdapter.fetchResponse
332
+ ? await requestAdapter.fetchResponse(request, {
323
333
  abortSignal: headerDeadline.signal,
324
334
  timeoutMs: connectTimeoutMs,
325
335
  returnRawErrors: true,
@@ -337,7 +347,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
337
347
  });
338
348
  },
339
349
  { abortSignal: headerDeadline.signal, label: "web-search-loop" },
340
- );
350
+ );
351
+ } finally {
352
+ request.releaseBodyObservation?.();
353
+ }
341
354
  return { response, responseAdapter: requestAdapter };
342
355
  };
343
356
 
@@ -385,6 +398,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
385
398
  }
386
399
  return prepared;
387
400
  } catch (error) {
401
+ if (isTranslatorBudgetExceededError(error)) throw error;
388
402
  if (headerDeadline.didExpire()) {
389
403
  throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
390
404
  }
@@ -412,6 +426,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
412
426
  for await (const event of parseStreamWithProgress(prepared.response, parse, {
413
427
  signal,
414
428
  inactivityTimeoutMs: routedModelStallTimeoutMs,
429
+ translatorBudget,
415
430
  })) {
416
431
  if (event.type === "heartbeat") yield event;
417
432
  // Kiro's explicit-completion protocol marks ordinary assistant text as commentary while
@@ -424,6 +439,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
424
439
  else events.push(event);
425
440
  }
426
441
  } catch (error) {
442
+ if (isTranslatorBudgetExceededError(error)) throw error;
427
443
  if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
428
444
  if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message);
429
445
  if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message);
@@ -436,7 +452,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
436
452
  throw new LoopError(502, `Web-search adapter stream protocol error: expected one final terminal event, received ${terminalIndexes.length}`);
437
453
  }
438
454
  const terminal = events[terminalIndexes[0]!];
439
- if (terminal.type === "error") throw new LoopError(502, terminal.message);
455
+ if (terminal.type === "error") {
456
+ if (terminal.code === "translation_buffer_limit") {
457
+ throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES);
458
+ }
459
+ throw new LoopError(502, terminal.message);
460
+ }
440
461
  return scanEventsForWebSearch(events);
441
462
  };
442
463
 
@@ -603,7 +624,17 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
603
624
  yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []);
604
625
  }
605
626
  } catch (e) {
606
- yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };
627
+ if (isTranslatorBudgetExceededError(e)) {
628
+ yield {
629
+ type: "error",
630
+ status: 502,
631
+ errorType: "upstream_error",
632
+ code: e.code,
633
+ message: "upstream translation buffer exceeded the safe limit",
634
+ };
635
+ } else {
636
+ yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };
637
+ }
607
638
  return;
608
639
  }
609
640
  }
@@ -621,6 +652,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
621
652
  internalAbort.abort("client closed responses stream");
622
653
  }, undefined,
623
654
  {
655
+ translatorBudget,
624
656
  ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
625
657
  hideThinkingSummary: parsed.options.hideThinkingSummary,
626
658
  ...(deps.stallTimeoutSec !== undefined ? { stallTimeoutSec: deps.stallTimeoutSec } : {}),