@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
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BDjpkcRN.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.0",
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 {
@@ -250,6 +251,67 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean {
250
251
  }
251
252
  }
252
253
 
254
+ /** Normalize provider baseUrl paths ending in `/`, `/v1`, or `/v1/messages` to `{origin}/v1/messages`. */
255
+ export function anthropicMessagesUrl(baseUrl: string): string {
256
+ try {
257
+ new URL(baseUrl);
258
+ } catch {
259
+ throw new Error(`anthropic provider has malformed baseUrl: ${baseUrl}`);
260
+ }
261
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
262
+ const root = trimmed.replace(/\/v1\/messages\/?$/i, "").replace(/\/v1\/?$/i, "").replace(/\/+$/, "");
263
+ return `${root}/v1/messages`;
264
+ }
265
+
266
+ function synthesizeToolUseId(): string {
267
+ return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
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
+
280
+ function toolUseArguments(input: unknown): string {
281
+ if (typeof input === "string") {
282
+ const trimmed = input.trim();
283
+ if (!trimmed) return "{}";
284
+ try {
285
+ JSON.parse(trimmed);
286
+ return trimmed;
287
+ } catch {
288
+ // A tool call's arguments must be a JSON object. Re-encoding an unparseable string as a
289
+ // JSON *string* is the double-encoding #765 reports: the caller then receives
290
+ // `"get weather"` where an object was required and the tool call is unusable either way.
291
+ // An empty object at least fails in the tool's own argument validation.
292
+ return "{}";
293
+ }
294
+ }
295
+ return JSON.stringify(input ?? {});
296
+ }
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
+
253
315
  function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean {
254
316
  return provider.apiKeyTransport === "bearer";
255
317
  }
@@ -680,8 +742,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
680
742
  else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(resolveToolChoiceWireName(parsed.context.tools, tc.name)) };
681
743
  }
682
744
 
683
- const base = provider.baseUrl.replace(/\/v1\/?$/, "");
684
- const url = `${base}/v1/messages`;
745
+ const url = anthropicMessagesUrl(provider.baseUrl);
685
746
  const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
686
747
  if (unresolvedPlaceholder) {
687
748
  throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
@@ -723,15 +784,17 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
723
784
  return { url, method: "POST", headers, body: JSON.stringify(body) };
724
785
  },
725
786
 
726
- async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
787
+ async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
727
788
  if (!response.body) {
728
789
  yield { type: "error", message: "No response body" };
729
790
  return;
730
791
  }
731
792
 
793
+ const budgetEncoder = new TextEncoder();
732
794
  let currentBlockType = "";
733
795
  let currentToolCallId = "";
734
796
  let currentToolCallName = "";
797
+ let currentToolCallJson = "";
735
798
  let pendingUsage: Record<string, number> | undefined;
736
799
  let pendingStopReason: string | undefined;
737
800
  let emittedDone = false;
@@ -746,7 +809,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
746
809
  };
747
810
  };
748
811
 
749
- 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 })) {
750
814
  if (record.kind === "comment") {
751
815
  yield { type: "heartbeat" };
752
816
  continue;
@@ -773,8 +837,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
773
837
  if (!block) break;
774
838
  currentBlockType = block.type;
775
839
  if (block.type === "tool_use") {
776
- currentToolCallId = block.id ?? "";
840
+ currentToolCallId = usableToolUseId(block.id);
777
841
  currentToolCallName = toolNames.fromWire(block.name ?? "");
842
+ currentToolCallJson = "";
843
+ budget.openCall(currentToolCallId);
778
844
  yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
779
845
  }
780
846
  if (block.type === "redacted_thinking" && typeof block.data === "string") {
@@ -799,15 +865,44 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
799
865
  // Arrives once, just before the thinking block's content_block_stop; block-scoped
800
866
  // so a stray signature on a non-thinking block can never be captured.
801
867
  yield { type: "thinking_signature", signature: delta.signature };
802
- } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
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
+ }
803
884
  yield { type: "tool_call_delta", arguments: delta.partial_json };
804
885
  }
805
886
  break;
806
887
  }
807
888
  case "content_block_stop": {
808
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
+ }
809
902
  yield { type: "tool_call_end" };
903
+ budget.closeCall(currentToolCallId);
810
904
  currentToolCallId = "";
905
+ currentToolCallJson = "";
811
906
  }
812
907
  currentBlockType = "";
813
908
  break;
@@ -830,13 +925,26 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
830
925
  }
831
926
  }
832
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
+ }
833
940
  if (!emittedDone) {
941
+ // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
834
942
  if (pendingStopReason !== undefined) {
835
943
  const stopReason = pendingStopReason === "max_tokens"
836
944
  ? "max_tokens"
837
945
  : pendingStopReason === "refusal" || pendingStopReason === "content_filter"
838
946
  ? "content_filter"
839
- : undefined;
947
+ : pendingStopReason;
840
948
  emittedDone = true;
841
949
  yield {
842
950
  type: "done",
@@ -849,8 +957,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
849
957
  }
850
958
  },
851
959
 
852
- async parseResponse(response: Response): Promise<AdapterEvent[]> {
960
+ async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
853
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 {
854
965
  const events: AdapterEvent[] = [];
855
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;
856
967
  if (content) {
@@ -867,8 +978,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
867
978
  } else if (block.type === "redacted_thinking" && typeof block.data === "string") {
868
979
  events.push({ type: "redacted_thinking", data: block.data });
869
980
  } else if (block.type === "tool_use") {
870
- events.push({ type: "tool_call_start", id: block.id ?? "", name: toolNames.fromWire(block.name ?? "") });
871
- events.push({ type: "tool_call_delta", arguments: JSON.stringify(block.input ?? {}) });
981
+ const id = usableToolUseId(block.id);
982
+ events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") });
983
+ events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) });
872
984
  events.push({ type: "tool_call_end" });
873
985
  }
874
986
  }
@@ -880,7 +992,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
880
992
  usage: usageFromAnthropic(usage),
881
993
  ...(stopReason ? { stopReason } : {}),
882
994
  });
995
+ retainTranslatedEventBatch(events, budget);
883
996
  return events;
997
+ } finally {
998
+ budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
999
+ }
884
1000
  },
885
1001
 
886
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
+ }