@bitkyc08/opencodex 2.8.2-preview.20260731 → 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 (166) 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 +86 -5
  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 +89 -14
  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/doctor.ts +12 -0
  42. package/src/cli/export-command.ts +187 -0
  43. package/src/cli/help.ts +11 -0
  44. package/src/cli/index.ts +13 -3
  45. package/src/cli/opencode.ts +36 -151
  46. package/src/cli/star-prompt.ts +13 -4
  47. package/src/cli/status-oauth.ts +12 -2
  48. package/src/clients/config-export.ts +377 -0
  49. package/src/codex/account-runtime-state.ts +19 -1
  50. package/src/codex/account-store.ts +162 -82
  51. package/src/codex/auth-api.ts +467 -159
  52. package/src/codex/auth-context.ts +15 -2
  53. package/src/codex/catalog/aggregation.ts +15 -0
  54. package/src/codex/catalog/effort.ts +16 -6
  55. package/src/codex/catalog/parsing.ts +3 -1
  56. package/src/codex/catalog/provider-fetch.ts +29 -0
  57. package/src/codex/catalog/sync.ts +64 -7
  58. package/src/codex/inject.ts +5 -5
  59. package/src/codex/main-account-cache.ts +8 -1
  60. package/src/codex/model-cache.ts +81 -2
  61. package/src/codex/pool-rotation.ts +39 -0
  62. package/src/codex/project-config-warnings.ts +12 -1
  63. package/src/codex/quota.ts +35 -3
  64. package/src/codex/routing.ts +46 -1
  65. package/src/codex/shim.ts +10 -4
  66. package/src/codex/subagent-model-fallback.ts +12 -0
  67. package/src/codex/websocket-registry.ts +27 -0
  68. package/src/combos/failover.ts +31 -1
  69. package/src/combos/request.ts +9 -0
  70. package/src/combos/resolve.ts +60 -4
  71. package/src/combos/types.ts +12 -0
  72. package/src/config.ts +510 -55
  73. package/src/github/star-state.ts +13 -1
  74. package/src/images/fulfill.ts +39 -1
  75. package/src/images/loop.ts +52 -12
  76. package/src/lib/admission.ts +83 -0
  77. package/src/lib/app-owned-memory-stores.ts +173 -0
  78. package/src/lib/app-owned-memory.ts +265 -0
  79. package/src/lib/bun-stream-caps.ts +31 -7
  80. package/src/lib/config-ownership.ts +33 -0
  81. package/src/lib/crash-guard.ts +65 -5
  82. package/src/lib/debug-log-buffer.ts +47 -6
  83. package/src/lib/errors.ts +3 -0
  84. package/src/lib/gcp-adc.ts +40 -2
  85. package/src/lib/injection-debug-log.ts +26 -2
  86. package/src/lib/sidecar-tracker.ts +5 -2
  87. package/src/lib/sse-decoder.ts +257 -37
  88. package/src/lib/state-store-registrations.ts +109 -0
  89. package/src/lib/state-store-sweeper.ts +184 -0
  90. package/src/lib/translator-budget.ts +356 -0
  91. package/src/lib/windows-secret-acl.ts +33 -12
  92. package/src/lib/winsw.ts +8 -1
  93. package/src/oauth/anthropic-routing.ts +31 -7
  94. package/src/oauth/google-antigravity.ts +2 -1
  95. package/src/oauth/health.ts +30 -12
  96. package/src/oauth/index.ts +127 -23
  97. package/src/oauth/kiro.ts +10 -2
  98. package/src/oauth/store.ts +165 -18
  99. package/src/oauth/token-guardian.ts +43 -4
  100. package/src/oauth/types.ts +2 -1
  101. package/src/providers/base-url-choices.ts +10 -0
  102. package/src/providers/derive.ts +12 -0
  103. package/src/providers/key-failover.ts +12 -0
  104. package/src/providers/openai-sidecar.ts +4 -1
  105. package/src/providers/quota.ts +68 -7
  106. package/src/providers/registry.ts +279 -3
  107. package/src/responses/parser.ts +5 -1
  108. package/src/responses/spill-store.ts +394 -0
  109. package/src/responses/state.ts +520 -102
  110. package/src/router.ts +18 -1
  111. package/src/server/adapter-resolve.ts +20 -3
  112. package/src/server/auth-cors.ts +121 -28
  113. package/src/server/chat-completions.ts +57 -12
  114. package/src/server/claude-messages.ts +85 -13
  115. package/src/server/index.ts +239 -97
  116. package/src/server/lifecycle.ts +155 -25
  117. package/src/server/management/agent-settings-routes.ts +75 -32
  118. package/src/server/management/api-key-usage.ts +167 -0
  119. package/src/server/management/body.ts +35 -0
  120. package/src/server/management/combo-routes.ts +5 -1
  121. package/src/server/management/config-routes.ts +37 -12
  122. package/src/server/management/logs-usage-routes.ts +41 -21
  123. package/src/server/management/model-routes.ts +173 -53
  124. package/src/server/management/oauth-account-routes.ts +115 -26
  125. package/src/server/management/provider-routes.ts +56 -6
  126. package/src/server/management/sidebar-routes.ts +50 -1
  127. package/src/server/management/system-restart.ts +13 -6
  128. package/src/server/management/system-routes.ts +15 -3
  129. package/src/server/management/usage-summary-cache.ts +86 -0
  130. package/src/server/management-api.ts +39 -5
  131. package/src/server/management-auth.ts +65 -14
  132. package/src/server/port-reclaim.ts +58 -12
  133. package/src/server/ports.ts +2 -0
  134. package/src/server/proxy-liveness.ts +51 -12
  135. package/src/server/relay-eager.ts +20 -4
  136. package/src/server/relay.ts +548 -154
  137. package/src/server/request-decompress.ts +51 -4
  138. package/src/server/request-log.ts +134 -15
  139. package/src/server/responses/collaboration.ts +15 -4
  140. package/src/server/responses/compact.ts +3 -0
  141. package/src/server/responses/core.ts +241 -66
  142. package/src/server/responses-image-gen-repair.ts +19 -5
  143. package/src/server/responses-item-id-repair.ts +23 -5
  144. package/src/server/sse-payload-rewrite.ts +71 -12
  145. package/src/server/startup-health-cache.ts +14 -1
  146. package/src/server/system-env.ts +8 -1
  147. package/src/server/windows-tcp-drop.ts +15 -5
  148. package/src/server/ws-bridge.ts +25 -0
  149. package/src/service.ts +23 -3
  150. package/src/storage/policy-job.ts +93 -23
  151. package/src/storage/policy-worker.ts +6 -0
  152. package/src/storage/restore-job.ts +62 -16
  153. package/src/storage/restore-worker.ts +6 -0
  154. package/src/storage/storage-mutation-coordinator.ts +36 -6
  155. package/src/storage/worker-lifecycle.ts +181 -47
  156. package/src/tray/windows.ts +45 -18
  157. package/src/types.ts +34 -6
  158. package/src/update/index.ts +24 -5
  159. package/src/update/job.ts +598 -73
  160. package/src/usage/log.ts +115 -17
  161. package/src/usage/summary.ts +67 -2
  162. package/src/vision/index.ts +112 -22
  163. package/src/web-search/loop.ts +38 -6
  164. package/src/web-search/progress-stream.ts +14 -3
  165. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
  166. package/gui/dist/assets/index-GC0Vlu1Z.js +0 -67
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-GC0Vlu1Z.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-BHsKRFh9.css">
19
+ <script type="module" crossorigin src="/assets/index-u5eFOv2y.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-CHwf3tTD.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.8.2-preview.20260731",
3
+ "version": "2.9.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -20,6 +20,7 @@ import {
20
20
  TOTAL_IMAGE_BASE64_BUDGET,
21
21
  type ImageBlockRef,
22
22
  } from "./anthropic-image-guard";
23
+ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
23
24
 
24
25
  /** One ladder position: dimension cap, JPEG quality attempts, per-image base64 cap. */
25
26
  export interface TierSpec {
@@ -98,50 +99,143 @@ type ProcessResult =
98
99
  * not entry count. Entries are immutable snapshots — demotions write NEW tier-suffixed
99
100
  * keys, never mutate stored values.
100
101
  */
101
- const CACHE_BYTE_CAP = 64 * MiB;
102
+ export const IMAGE_NORMALIZE_CACHE_MAX_BYTES = 64 * MiB;
103
+ const CACHE_MAX_ENTRIES = 4_096;
104
+ const CACHE_MAX_ENTRY_BYTES = 20 * MiB;
102
105
  // "pass" = validated pass-through; "miss" = this position's ladder cannot meet its hard
103
106
  // cap for these bytes (skip straight to the next position — C-gate round 2, blocker 1).
104
- const cache = new Map<string, { data: string; mediaType: string } | "pass" | "miss">();
107
+ type CacheValue = { data: string; mediaType: string } | "pass" | "miss";
108
+ interface CacheEntry {
109
+ value: CacheValue;
110
+ sizeBytes: number;
111
+ metadataBytes: number;
112
+ storedAt: number;
113
+ }
114
+ interface NormalizeCacheLimits {
115
+ maxBytes: number;
116
+ maxEntries: number;
117
+ maxEntryBytes: number;
118
+ }
119
+ const DEFAULT_CACHE_LIMITS: NormalizeCacheLimits = {
120
+ maxBytes: IMAGE_NORMALIZE_CACHE_MAX_BYTES,
121
+ maxEntries: CACHE_MAX_ENTRIES,
122
+ maxEntryBytes: CACHE_MAX_ENTRY_BYTES,
123
+ };
124
+ const cacheEncoder = new TextEncoder();
125
+ const cache = new Map<string, CacheEntry>();
126
+ let cacheLimits = { ...DEFAULT_CACHE_LIMITS };
105
127
  let cacheBytes = 0;
128
+ let cacheMetadataBytes = 0;
129
+ let cacheSentinelEntries = 0;
106
130
  let encodeCalls = 0;
107
131
 
108
- function cachePut(key: string, value: { data: string; mediaType: string } | "pass" | "miss"): void {
109
- const size = typeof value === "string" ? 0 : value.data.length;
132
+ function cacheEntry(key: string, value: CacheValue): CacheEntry {
133
+ const keyBytes = cacheEncoder.encode(key).byteLength;
134
+ const valueBytes = typeof value === "string"
135
+ ? cacheEncoder.encode(value).byteLength
136
+ : cacheEncoder.encode(value.mediaType).byteLength + cacheEncoder.encode(value.data).byteLength;
137
+ const metadataBytes = keyBytes + (typeof value === "string"
138
+ ? cacheEncoder.encode(value).byteLength
139
+ : cacheEncoder.encode(value.mediaType).byteLength);
140
+ return { value, sizeBytes: keyBytes + valueBytes, metadataBytes, storedAt: Date.now() };
141
+ }
142
+
143
+ function deleteCacheEntry(key: string): number {
144
+ const entry = cache.get(key);
145
+ if (!entry) return 0;
146
+ cache.delete(key);
147
+ cacheBytes -= entry.sizeBytes;
148
+ cacheMetadataBytes -= entry.metadataBytes;
149
+ if (typeof entry.value === "string") cacheSentinelEntries--;
150
+ return entry.sizeBytes;
151
+ }
152
+
153
+ function cachePut(key: string, value: CacheValue): boolean {
154
+ const next = cacheEntry(key, value);
155
+ if (
156
+ next.sizeBytes > cacheLimits.maxEntryBytes
157
+ || next.sizeBytes > cacheLimits.maxBytes
158
+ || cacheLimits.maxEntries <= 0
159
+ ) return false;
110
160
  const existing = cache.get(key);
111
161
  if (existing !== undefined) {
112
- cacheBytes -= typeof existing === "string" ? 0 : existing.data.length;
113
- cache.delete(key); // re-insert refreshes recency and prevents double-count on concurrent misses
162
+ deleteCacheEntry(key); // re-insert refreshes recency and prevents double-count on concurrent misses
114
163
  }
115
- while (cacheBytes + size > CACHE_BYTE_CAP && cache.size > 0) {
116
- const oldest = cache.keys().next().value as string;
117
- const evicted = cache.get(oldest);
118
- cacheBytes -= typeof evicted === "string" || evicted === undefined ? 0 : evicted.data.length;
119
- cache.delete(oldest);
164
+ while (cache.size + 1 > cacheLimits.maxEntries || cacheBytes + next.sizeBytes > cacheLimits.maxBytes) {
165
+ const oldest = cache.keys().next().value;
166
+ if (oldest === undefined || deleteCacheEntry(oldest) === 0) return false;
120
167
  }
121
- cache.set(key, value);
122
- cacheBytes += size;
168
+ cache.set(key, next);
169
+ cacheBytes += next.sizeBytes;
170
+ cacheMetadataBytes += next.metadataBytes;
171
+ if (typeof value === "string") cacheSentinelEntries++;
172
+ enforceAppOwnedMemoryBudget();
173
+ return true;
123
174
  }
124
175
 
125
176
  /** Read a cache entry, refreshing its recency (true LRU, C-gate round 1 blocker 5). */
126
- function cacheGet(key: string): { data: string; mediaType: string } | "pass" | "miss" | undefined {
127
- const value = cache.get(key);
128
- if (value !== undefined) {
177
+ function cacheGet(key: string): CacheValue | undefined {
178
+ const entry = cache.get(key);
179
+ if (entry !== undefined) {
129
180
  cache.delete(key);
130
- cache.set(key, value);
181
+ entry.storedAt = Date.now();
182
+ cache.set(key, entry);
131
183
  }
132
- return value;
184
+ return entry?.value;
133
185
  }
134
186
 
135
187
  /** Test hooks: encoder-invocation counter + cache reset (no production caller). */
136
- export function getNormalizeStatsForTests(): { encodeCalls: number; cacheEntries: number; cacheBytes: number } {
137
- return { encodeCalls, cacheEntries: cache.size, cacheBytes };
188
+ export function getNormalizeStatsForTests(): {
189
+ encodeCalls: number;
190
+ cacheEntries: number;
191
+ cacheBytes: number;
192
+ sentinelEntries: number;
193
+ metadataBytes: number;
194
+ oldestAt: number | null;
195
+ } {
196
+ return {
197
+ encodeCalls,
198
+ cacheEntries: cache.size,
199
+ cacheBytes,
200
+ sentinelEntries: cacheSentinelEntries,
201
+ metadataBytes: cacheMetadataBytes,
202
+ oldestAt: cache.values().next().value?.storedAt ?? null,
203
+ };
138
204
  }
139
205
  export function resetNormalizeStateForTests(): void {
140
206
  cache.clear();
141
207
  cacheBytes = 0;
208
+ cacheMetadataBytes = 0;
209
+ cacheSentinelEntries = 0;
142
210
  encodeCalls = 0;
143
211
  }
144
212
 
213
+ export function setNormalizeCacheLimitsForTests(limits?: Partial<NormalizeCacheLimits>): void {
214
+ resetNormalizeStateForTests();
215
+ cacheLimits = limits ? { ...DEFAULT_CACHE_LIMITS, ...limits } : { ...DEFAULT_CACHE_LIMITS };
216
+ }
217
+
218
+ export function anthropicImageNormalizeRetainedStoreSnapshot(): {
219
+ count: number;
220
+ bytes: number;
221
+ evictableBytes: number;
222
+ pinnedBytes: number;
223
+ oldestAt: number | null;
224
+ } {
225
+ return {
226
+ count: cache.size,
227
+ bytes: cacheBytes,
228
+ evictableBytes: cacheBytes,
229
+ pinnedBytes: 0,
230
+ oldestAt: cache.values().next().value?.storedAt ?? null,
231
+ };
232
+ }
233
+
234
+ export function evictOldestAnthropicImageNormalizeForBudget(): number {
235
+ const oldest = cache.keys().next().value;
236
+ return oldest === undefined ? 0 : deleteCacheEntry(oldest);
237
+ }
238
+
145
239
  /** Default encoder: Bun.Image resize-to-fit + JPEG at the given quality. */
146
240
  const bunImageEncode: EncodeFn = async (input, spec, quality) => {
147
241
  const image = new Bun.Image(input);
@@ -22,6 +22,7 @@ import { neutralizeIdentity } from "./identity";
22
22
  import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint";
23
23
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
24
24
  import { decodeServerSentEvents } from "../lib/sse-decoder";
25
+ import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget";
25
26
 
26
27
  /** Map a user content part to an Anthropic content block (text or image source). */
27
28
  function toAnthropicContentPart(p: OcxContentPart): unknown {
@@ -266,6 +267,16 @@ function synthesizeToolUseId(): string {
266
267
  return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
267
268
  }
268
269
 
270
+ /**
271
+ * A tool_use id that a client can actually echo back. `??` only catches a missing
272
+ * field, so an Anthropic-compatible relay that sends `""` or `" "` produced a
273
+ * call whose id round-trips as blank — the next turn then cannot pair the result
274
+ * with its call. Treat blank as absent and synthesize (#765).
275
+ */
276
+ function usableToolUseId(id: unknown): string {
277
+ return typeof id === "string" && id.trim() ? id : synthesizeToolUseId();
278
+ }
279
+
269
280
  function toolUseArguments(input: unknown): string {
270
281
  if (typeof input === "string") {
271
282
  const trimmed = input.trim();
@@ -284,6 +295,23 @@ function toolUseArguments(input: unknown): string {
284
295
  return JSON.stringify(input ?? {});
285
296
  }
286
297
 
298
+ /**
299
+ * Whether arguments assembled from a stream's `input_json_delta` fragments are usable.
300
+ * A tool block that sent no fragments at all is fine — that is a no-argument call. Anything
301
+ * else has to parse, because unlike the non-stream path the fragments have already been
302
+ * forwarded to the client and cannot be repaired after the fact.
303
+ */
304
+ function streamedToolArgumentsParse(assembled: string): boolean {
305
+ const trimmed = assembled.trim();
306
+ if (!trimmed) return true;
307
+ try {
308
+ JSON.parse(trimmed);
309
+ return true;
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+
287
315
  function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean {
288
316
  return provider.apiKeyTransport === "bearer";
289
317
  }
@@ -756,15 +784,17 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
756
784
  return { url, method: "POST", headers, body: JSON.stringify(body) };
757
785
  },
758
786
 
759
- async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
787
+ async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
760
788
  if (!response.body) {
761
789
  yield { type: "error", message: "No response body" };
762
790
  return;
763
791
  }
764
792
 
793
+ const budgetEncoder = new TextEncoder();
765
794
  let currentBlockType = "";
766
795
  let currentToolCallId = "";
767
796
  let currentToolCallName = "";
797
+ let currentToolCallJson = "";
768
798
  let pendingUsage: Record<string, number> | undefined;
769
799
  let pendingStopReason: string | undefined;
770
800
  let emittedDone = false;
@@ -779,7 +809,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
779
809
  };
780
810
  };
781
811
 
782
- for await (const record of decodeServerSentEvents(response.body, { includeComments: true })) {
812
+ try {
813
+ for await (const record of decodeServerSentEvents(response.body, { includeComments: true, translatorBudget: budget })) {
783
814
  if (record.kind === "comment") {
784
815
  yield { type: "heartbeat" };
785
816
  continue;
@@ -806,8 +837,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
806
837
  if (!block) break;
807
838
  currentBlockType = block.type;
808
839
  if (block.type === "tool_use") {
809
- currentToolCallId = block.id ?? synthesizeToolUseId();
840
+ currentToolCallId = usableToolUseId(block.id);
810
841
  currentToolCallName = toolNames.fromWire(block.name ?? "");
842
+ currentToolCallJson = "";
843
+ budget.openCall(currentToolCallId);
811
844
  yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
812
845
  }
813
846
  if (block.type === "redacted_thinking" && typeof block.data === "string") {
@@ -833,14 +866,43 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
833
866
  // so a stray signature on a non-thinking block can never be captured.
834
867
  yield { type: "thinking_signature", signature: delta.signature };
835
868
  } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") {
869
+ // Forwarded immediately: the bridge maps each delta to a client-visible
870
+ // response.function_call_arguments.delta frame, so withholding fragments until
871
+ // block close would leave a started call showing empty arguments. A copy is kept
872
+ // to validate the assembled payload at content_block_stop.
873
+ const previousBytes = budgetEncoder.encode(currentToolCallJson).byteLength;
874
+ const nextBytes = previousBytes + budgetEncoder.encode(delta.partial_json).byteLength;
875
+ const reservation = budget.reserveTransient(nextBytes, { kind: "tool_args", callId: currentToolCallId });
876
+ try {
877
+ currentToolCallJson += delta.partial_json;
878
+ reservation.commitRetained();
879
+ budget.releaseRetained(previousBytes, { kind: "tool_args", callId: currentToolCallId });
880
+ } catch (error) {
881
+ reservation.release();
882
+ throw error;
883
+ }
836
884
  yield { type: "tool_call_delta", arguments: delta.partial_json };
837
885
  }
838
886
  break;
839
887
  }
840
888
  case "content_block_stop": {
841
889
  if (currentBlockType === "tool_use") {
890
+ // The non-stream path repairs an unparseable payload in toolUseArguments(); the
891
+ // stream cannot, because the fragments are already downstream. Fail the turn
892
+ // instead of ending a tool call whose arguments will not parse — the bridge's
893
+ // terminal-error path cancels the open call (status incomplete) rather than
894
+ // completing it before response.failed (#765).
895
+ if (!streamedToolArgumentsParse(currentToolCallJson)) {
896
+ yield {
897
+ type: "error",
898
+ message: "Anthropic stream sent malformed tool_use arguments (invalid JSON)",
899
+ };
900
+ return;
901
+ }
842
902
  yield { type: "tool_call_end" };
903
+ budget.closeCall(currentToolCallId);
843
904
  currentToolCallId = "";
905
+ currentToolCallJson = "";
844
906
  }
845
907
  currentBlockType = "";
846
908
  break;
@@ -863,6 +925,18 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
863
925
  }
864
926
  }
865
927
  }
928
+ } catch (error) {
929
+ if (!isTranslatorBudgetExceededError(error)) throw error;
930
+ yield {
931
+ type: "error",
932
+ status: 502,
933
+ errorType: "upstream_error",
934
+ code: "translation_buffer_limit",
935
+ message: "upstream translation buffer exceeded the safe limit",
936
+ };
937
+ } finally {
938
+ if (currentToolCallId) budget.closeCall(currentToolCallId);
939
+ }
866
940
  if (!emittedDone) {
867
941
  // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
868
942
  if (pendingStopReason !== undefined) {
@@ -883,8 +957,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
883
957
  }
884
958
  },
885
959
 
886
- async parseResponse(response: Response): Promise<AdapterEvent[]> {
960
+ async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
887
961
  const json = await response.json() as Record<string, unknown>;
962
+ const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
963
+ budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
964
+ try {
888
965
  const events: AdapterEvent[] = [];
889
966
  const content = json.content as { type: string; text?: string; id?: string; name?: string; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined;
890
967
  if (content) {
@@ -901,7 +978,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
901
978
  } else if (block.type === "redacted_thinking" && typeof block.data === "string") {
902
979
  events.push({ type: "redacted_thinking", data: block.data });
903
980
  } else if (block.type === "tool_use") {
904
- const id = block.id ?? synthesizeToolUseId();
981
+ const id = usableToolUseId(block.id);
905
982
  events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") });
906
983
  events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) });
907
984
  events.push({ type: "tool_call_end" });
@@ -915,7 +992,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
915
992
  usage: usageFromAnthropic(usage),
916
993
  ...(stopReason ? { stopReason } : {}),
917
994
  });
995
+ retainTranslatedEventBatch(events, budget);
918
996
  return events;
997
+ } finally {
998
+ budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
999
+ }
919
1000
  },
920
1001
 
921
1002
  };
@@ -1,4 +1,4 @@
1
- import type { ProviderAdapter } from "./base";
1
+ import type { IncomingMeta, ProviderAdapter } from "./base";
2
2
  import type { OcxParsedRequest, OcxProviderConfig } from "../types";
3
3
  import { createResponsesPassthroughAdapter } from "./openai-responses";
4
4
 
@@ -12,7 +12,7 @@ export function createAzureAdapter(provider: OcxProviderConfig): ProviderAdapter
12
12
  ...inner,
13
13
  name: "azure-openai",
14
14
 
15
- async buildRequest(parsed: OcxParsedRequest) {
15
+ async buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) {
16
16
  if (provider.authMode === "forward") {
17
17
  throw new Error("azure-openai does not support forward auth mode");
18
18
  }
@@ -20,7 +20,7 @@ export function createAzureAdapter(provider: OcxProviderConfig): ProviderAdapter
20
20
  throw new Error("azure-openai requires a non-empty apiKey");
21
21
  }
22
22
 
23
- const request = await inner.buildRequest(parsed);
23
+ const request = await inner.buildRequest(parsed, incoming);
24
24
  const unresolvedPlaceholder = request.url.match(/\{[^}]*\}/)?.[0] ?? request.url.match(/[{}]/)?.[0];
25
25
  if (unresolvedPlaceholder) {
26
26
  throw new Error(`azure-openai baseUrl contains unresolved ${unresolvedPlaceholder} — set your real resource URL`);
@@ -1,8 +1,10 @@
1
1
  import type { AdapterEvent, OcxParsedRequest } from "../types";
2
+ import type { TranslatorBudget } from "../lib/translator-budget";
2
3
 
3
4
  /** Metadata about the caller's incoming request, for auth-forwarding adapters. */
4
5
  export interface IncomingMeta {
5
6
  headers: Headers;
7
+ translatorBudget: TranslatorBudget;
6
8
  abortSignal?: AbortSignal;
7
9
  /**
8
10
  * Image-normalization ladder bias for upstream-413 tightened retries: every image
@@ -26,12 +28,12 @@ export interface ProviderAdapter {
26
28
  * (e.g. Vertex AI ADC token) return a Promise. Sync adapters return the object directly; callers
27
29
  * must `await` the result (awaiting a non-Promise is a no-op).
28
30
  */
29
- buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta): AdapterRequest | Promise<AdapterRequest>;
31
+ buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta): AdapterRequest | Promise<AdapterRequest>;
30
32
 
31
33
  fetchResponse?(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response>;
32
34
 
33
- parseStream(response: Response): AsyncGenerator<AdapterEvent>;
34
- parseResponse?(response: Response): Promise<AdapterEvent[]>;
35
+ parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent>;
36
+ parseResponse?(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]>;
35
37
  runTurn?(
36
38
  parsed: OcxParsedRequest,
37
39
  incoming: IncomingMeta,
@@ -44,6 +46,8 @@ export interface AdapterRequest {
44
46
  method: string;
45
47
  headers: Record<string, string>;
46
48
  body: string;
49
+ /** Releases observation of a serialized request body after its final fetch attempt settles. */
50
+ releaseBodyObservation?: () => void;
47
51
  /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */
48
52
  reasoningLog?: {
49
53
  effectiveEffort: string;
@@ -1,4 +1,8 @@
1
- import { CANONICAL_EFFORT_SUFFIXES, cursorModelEffortLadder } from "./effort-map";
1
+ import {
2
+ CANONICAL_EFFORT_SUFFIXES,
3
+ cursorModelEffortLadder,
4
+ cursorWireModelIdWithEffort,
5
+ } from "./effort-map";
2
6
 
3
7
  export interface CursorModelInfo {
4
8
  id: string;
@@ -66,14 +70,20 @@ function stripCursorWirePrefix(id: string): string {
66
70
 
67
71
  /**
68
72
  * True when a configured Cursor base model should remain exposed after live GetUsableModels filtering.
69
- * Live ids are full effort-suffixed variants (`claude-4.6-opus-high`); base ids match exactly or by prefix.
73
+ * Live ids are full effort-suffixed variants (`claude-4.6-opus-high`); base ids match exactly, the
74
+ * ordinary `{base}-{effort}` form, or Cursor's current `{base-without-fast}-{effort}-fast` form.
70
75
  */
71
76
  export function isCursorModelAvailableForAccount(modelId: string, liveIds: readonly string[]): boolean {
72
77
  return liveIds.some(raw => {
73
78
  const id = stripCursorWirePrefix(raw);
74
79
  if (id === modelId) return true;
75
- const effortPrefix = `${modelId}-`;
76
- return id.startsWith(effortPrefix) && CANONICAL_EFFORT_SUFFIXES.has(id.slice(effortPrefix.length));
80
+ for (const effort of CANONICAL_EFFORT_SUFFIXES) {
81
+ if (
82
+ id === `${modelId}-${effort}` ||
83
+ id === cursorWireModelIdWithEffort(modelId, effort)
84
+ ) return true;
85
+ }
86
+ return false;
77
87
  });
78
88
  }
79
89
 
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Cursor model ids encode the reasoning effort as a suffix (`claude-4.6-opus-high`), and the available
5
5
  * tiers differ per model — `claude-4.6-opus` tops out at `-max`, `claude-opus-4-8` at `-xhigh`,
6
- * `claude-4.6-sonnet` only has `-medium`, and `composer`/`grok`/`gemini` take no suffix at all. A bare
7
- * id for a model that requires a suffix is rejected `ERROR_BAD_MODEL_NAME` (devlog 350.105).
6
+ * `claude-4.6-sonnet` only has `-medium`, and most `composer`/`gemini` models take no suffix at all.
7
+ * Grok Fast puts its mode marker after the effort (`grok-4.5-high-fast`). A bare id for a model that
8
+ * requires a suffix is rejected `ERROR_BAD_MODEL_NAME` (devlog 350.105).
8
9
  *
9
10
  * Canonical effort order is always low < medium < high < xhigh < max (max is the top tier, confirmed
10
11
  * against Anthropic docs and Cursor's live lineup). Tiers are stored in ascending canonical order.
@@ -30,10 +31,10 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
30
31
  // GetUsableModels (2026-07-28) lists kimi-k3 only as effort-suffixed kimi-k3-{low,high,max};
31
32
  // the bare id returns not_found. Tiers mirror the native Kimi provider's K3 ladder.
32
33
  "kimi-k3": ["low", "high", "max"],
33
- // GetUsableModels (2026-07-09) lists grok-4.5-{medium,high,xhigh} and grok-4.5-fast-{medium,high,xhigh};
34
- // the bare "grok-4.5-fast" id was removed upstream and now returns not_found.
35
- "grok-4.5": ["medium", "high", "xhigh"],
36
- "grok-4.5-fast": ["medium", "high", "xhigh"],
34
+ // Cursor renamed the Grok 4.5 slugs to cursor-grok-4.5-{low,medium,high} and
35
+ // cursor-grok-4.5-{low,medium,high}-fast. The bare Fast id returns not_found.
36
+ "grok-4.5": ["low", "medium", "high"],
37
+ "grok-4.5-fast": ["low", "medium", "high"],
37
38
  "gpt-5.1": ["low", "high"],
38
39
  "gpt-5.1-codex-max": ["low", "medium", "high", "xhigh"],
39
40
  "gpt-5.1-codex-mini": ["low", "high"],
@@ -113,3 +114,14 @@ export function cursorModelEffortLadder(baseModelId: string): string[] | undefin
113
114
  export function cursorModelHasEffortTiers(baseModelId: string): boolean {
114
115
  return (CURSOR_MODEL_EFFORT_TIERS[baseModelId]?.length ?? 0) > 0;
115
116
  }
117
+
118
+ /**
119
+ * Compose a Cursor wire id from a Codex-facing base id and effort tier.
120
+ * Fast variants put the mode after the effort; other models use the ordinary `{base}-{effort}` form.
121
+ */
122
+ export function cursorWireModelIdWithEffort(baseModelId: string, effortSuffix: string): string {
123
+ if (baseModelId.endsWith("-fast")) {
124
+ return `${baseModelId.slice(0, -"-fast".length)}-${effortSuffix}-fast`;
125
+ }
126
+ return `${baseModelId}-${effortSuffix}`;
127
+ }