@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BU1tE0sr.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DL9-iS6J.css">
19
+ <script type="module" crossorigin src="/assets/index-Djowl68T.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-DS1NE4Jn.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.42.0",
3
+ "version": "2.43.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",
@@ -0,0 +1,304 @@
1
+ import { sniffImageDimensions } from "./anthropic-image-guard";
2
+ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
3
+
4
+ /** One ladder position: dimension cap, JPEG quality attempts, per-image base64 cap. */
5
+ export interface TierSpec {
6
+ maxEdge: number;
7
+ qualities: number[];
8
+ /** Hard per-image base64-length cap at this position; Infinity = terminal (measured size accepted). */
9
+ hardCap: number;
10
+ }
11
+
12
+ const KiB = 1024;
13
+ const MiB = 1024 * 1024;
14
+
15
+ /**
16
+ * Ladder positions 0-5. 0-2 are the age-assigned tiers; 3-5 are demotion floor steps.
17
+ * Terminal (last) accepts its measured output so the aggregate loop always terminates
18
+ * (audit round 2, blocker 1).
19
+ */
20
+ export const TIER_SPECS: TierSpec[] = [
21
+ { maxEdge: 2000, qualities: [80, 60, 40, 30], hardCap: 2 * MiB },
22
+ { maxEdge: 1024, qualities: [70, 50], hardCap: 512 * KiB },
23
+ { maxEdge: 700, qualities: [60, 40], hardCap: 192 * KiB },
24
+ { maxEdge: 500, qualities: [40], hardCap: 100 * KiB },
25
+ { maxEdge: 400, qualities: [30], hardCap: 100 * KiB },
26
+ { maxEdge: 320, qualities: [25], hardCap: Infinity },
27
+ ];
28
+ export const TERMINAL_POS = TIER_SPECS.length - 1;
29
+
30
+ /** Newest 6 images ride tier 0, the next 14 tier 1, the rest tier 2 (020 tier table). */
31
+ export const TIER0_COUNT = 6;
32
+ export const TIER1_COUNT = 14;
33
+
34
+ /** Decode-bomb guards: refuse to decode absurd inputs (020 guards; "extreme values excluded"). */
35
+ export const MAX_INPUT_BASE64_LENGTH = 64 * MiB;
36
+
37
+ /**
38
+ * First-pass worker-pool width. Memory-bound, not CPU-bound: each in-flight item can
39
+ * hold a decoded bitmap, so this bounds peak memory to ~4 decoded images while still
40
+ * overlapping I/O and native-encode threadpool work. Fixed on purpose — a config knob
41
+ * would widen the adapter contract with no demonstrated need.
42
+ */
43
+ export const IMAGE_NORMALIZE_CONCURRENCY = 4;
44
+ export const MAX_INPUT_PIXELS = 100_000_000;
45
+
46
+
47
+ /** Formats Anthropic accepts as-is; anything else must be transcoded or dropped. */
48
+ const PASSTHROUGH_MEDIA = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
49
+
50
+ export interface NormalizeOptions {
51
+ /** Shift every image's starting ladder position down (413 retry tightening; 030). */
52
+ tierBias?: number;
53
+ /** Test seam: replaces the Bun.Image encode path (audit round 1, blocker 6). */
54
+ encode?: EncodeFn;
55
+ /** Test seam: replaces the pass-through decode validation (C-gate round 1, blocker 1). */
56
+ validate?: ValidateFn;
57
+ }
58
+
59
+ export type EncodeFn = (
60
+ input: Uint8Array,
61
+ spec: TierSpec,
62
+ quality: number,
63
+ ) => Promise<{ data: string; mediaType: string }>;
64
+
65
+ /** Proves the payload fully decodes; must throw for corrupt/truncated data. */
66
+ export type ValidateFn = (input: Uint8Array) => Promise<void>;
67
+
68
+ type ProcessResult =
69
+ | { kind: "pass"; b64Length: number }
70
+ | { kind: "encoded"; data: string; mediaType: string }
71
+ | { kind: "failed" };
72
+
73
+ /**
74
+ * Byte-weighted LRU over normalized outputs (audit round 1, blocker 2): aggregate cap,
75
+ * not entry count. Entries are immutable snapshots — demotions write NEW tier-suffixed
76
+ * keys, never mutate stored values.
77
+ */
78
+ export const IMAGE_NORMALIZE_CACHE_MAX_BYTES = 64 * MiB;
79
+ const CACHE_MAX_ENTRIES = 4_096;
80
+ const CACHE_MAX_ENTRY_BYTES = 20 * MiB;
81
+ // "pass" = validated pass-through; "miss" = this position's ladder cannot meet its hard
82
+ // cap for these bytes (skip straight to the next position — C-gate round 2, blocker 1).
83
+ type CacheValue = { data: string; mediaType: string } | "pass" | "miss";
84
+ interface CacheEntry {
85
+ value: CacheValue;
86
+ sizeBytes: number;
87
+ metadataBytes: number;
88
+ storedAt: number;
89
+ }
90
+ interface NormalizeCacheLimits {
91
+ maxBytes: number;
92
+ maxEntries: number;
93
+ maxEntryBytes: number;
94
+ }
95
+ const DEFAULT_CACHE_LIMITS: NormalizeCacheLimits = {
96
+ maxBytes: IMAGE_NORMALIZE_CACHE_MAX_BYTES,
97
+ maxEntries: CACHE_MAX_ENTRIES,
98
+ maxEntryBytes: CACHE_MAX_ENTRY_BYTES,
99
+ };
100
+ const cacheEncoder = new TextEncoder();
101
+ const cache = new Map<string, CacheEntry>();
102
+ let cacheLimits = { ...DEFAULT_CACHE_LIMITS };
103
+ let cacheBytes = 0;
104
+ let cacheMetadataBytes = 0;
105
+ let cacheSentinelEntries = 0;
106
+ let encodeCalls = 0;
107
+
108
+ function cacheEntry(key: string, value: CacheValue): CacheEntry {
109
+ const keyBytes = cacheEncoder.encode(key).byteLength;
110
+ const valueBytes = typeof value === "string"
111
+ ? cacheEncoder.encode(value).byteLength
112
+ : cacheEncoder.encode(value.mediaType).byteLength + cacheEncoder.encode(value.data).byteLength;
113
+ const metadataBytes = keyBytes + (typeof value === "string"
114
+ ? cacheEncoder.encode(value).byteLength
115
+ : cacheEncoder.encode(value.mediaType).byteLength);
116
+ return { value, sizeBytes: keyBytes + valueBytes, metadataBytes, storedAt: Date.now() };
117
+ }
118
+
119
+ function deleteCacheEntry(key: string): number {
120
+ const entry = cache.get(key);
121
+ if (!entry) return 0;
122
+ cache.delete(key);
123
+ cacheBytes -= entry.sizeBytes;
124
+ cacheMetadataBytes -= entry.metadataBytes;
125
+ if (typeof entry.value === "string") cacheSentinelEntries--;
126
+ return entry.sizeBytes;
127
+ }
128
+
129
+ function cachePut(key: string, value: CacheValue): boolean {
130
+ const next = cacheEntry(key, value);
131
+ if (
132
+ next.sizeBytes > cacheLimits.maxEntryBytes
133
+ || next.sizeBytes > cacheLimits.maxBytes
134
+ || cacheLimits.maxEntries <= 0
135
+ ) return false;
136
+ const existing = cache.get(key);
137
+ if (existing !== undefined) {
138
+ deleteCacheEntry(key); // re-insert refreshes recency and prevents double-count on concurrent misses
139
+ }
140
+ while (cache.size + 1 > cacheLimits.maxEntries || cacheBytes + next.sizeBytes > cacheLimits.maxBytes) {
141
+ const oldest = cache.keys().next().value;
142
+ if (oldest === undefined || deleteCacheEntry(oldest) === 0) return false;
143
+ }
144
+ cache.set(key, next);
145
+ cacheBytes += next.sizeBytes;
146
+ cacheMetadataBytes += next.metadataBytes;
147
+ if (typeof value === "string") cacheSentinelEntries++;
148
+ enforceAppOwnedMemoryBudget();
149
+ return true;
150
+ }
151
+
152
+ /** Read a cache entry, refreshing its recency (true LRU, C-gate round 1 blocker 5). */
153
+ function cacheGet(key: string): CacheValue | undefined {
154
+ const entry = cache.get(key);
155
+ if (entry !== undefined) {
156
+ cache.delete(key);
157
+ entry.storedAt = Date.now();
158
+ cache.set(key, entry);
159
+ }
160
+ return entry?.value;
161
+ }
162
+
163
+ /** Test hooks: encoder-invocation counter + cache reset (no production caller). */
164
+ export function getNormalizeStatsForTests(): {
165
+ encodeCalls: number;
166
+ cacheEntries: number;
167
+ cacheBytes: number;
168
+ sentinelEntries: number;
169
+ metadataBytes: number;
170
+ oldestAt: number | null;
171
+ } {
172
+ return {
173
+ encodeCalls,
174
+ cacheEntries: cache.size,
175
+ cacheBytes,
176
+ sentinelEntries: cacheSentinelEntries,
177
+ metadataBytes: cacheMetadataBytes,
178
+ oldestAt: cache.values().next().value?.storedAt ?? null,
179
+ };
180
+ }
181
+ export function resetNormalizeStateForTests(): void {
182
+ cache.clear();
183
+ cacheBytes = 0;
184
+ cacheMetadataBytes = 0;
185
+ cacheSentinelEntries = 0;
186
+ encodeCalls = 0;
187
+ }
188
+
189
+ export function setNormalizeCacheLimitsForTests(limits?: Partial<NormalizeCacheLimits>): void {
190
+ resetNormalizeStateForTests();
191
+ cacheLimits = limits ? { ...DEFAULT_CACHE_LIMITS, ...limits } : { ...DEFAULT_CACHE_LIMITS };
192
+ }
193
+
194
+ export function anthropicImageNormalizeRetainedStoreSnapshot(): {
195
+ count: number;
196
+ bytes: number;
197
+ evictableBytes: number;
198
+ pinnedBytes: number;
199
+ oldestAt: number | null;
200
+ } {
201
+ return {
202
+ count: cache.size,
203
+ bytes: cacheBytes,
204
+ evictableBytes: cacheBytes,
205
+ pinnedBytes: 0,
206
+ oldestAt: cache.values().next().value?.storedAt ?? null,
207
+ };
208
+ }
209
+
210
+ export function evictOldestAnthropicImageNormalizeForBudget(): number {
211
+ const oldest = cache.keys().next().value;
212
+ return oldest === undefined ? 0 : deleteCacheEntry(oldest);
213
+ }
214
+
215
+ /** Default encoder: Bun.Image resize-to-fit + JPEG at the given quality. */
216
+ export const bunImageEncode: EncodeFn = async (input, spec, quality) => {
217
+ const image = new Bun.Image(input);
218
+ const meta = await image.metadata();
219
+ const w = typeof meta.width === "number" ? meta.width : 0;
220
+ const h = typeof meta.height === "number" ? meta.height : 0;
221
+ let pipeline = new Bun.Image(input);
222
+ if (w > spec.maxEdge || h > spec.maxEdge) {
223
+ const scale = spec.maxEdge / Math.max(w, h);
224
+ pipeline = pipeline.resize(Math.max(1, Math.round(w * scale)), Math.max(1, Math.round(h * scale)));
225
+ }
226
+ const out = await pipeline.jpeg({ quality }).toBuffer();
227
+ return { data: Buffer.from(out).toString("base64"), mediaType: "image/jpeg" };
228
+ };
229
+
230
+ /**
231
+ * Default pass-through validation: force a full decode (resize forces pixel decoding, a
232
+ * header-only metadata read does not). A sniffable-but-truncated payload must throw here
233
+ * instead of riding pass-through to an Anthropic 400 (C-gate round 1, blocker 1).
234
+ */
235
+ export const bunImageValidate: ValidateFn = async input => {
236
+ await new Bun.Image(input).resize(1, 1).jpeg({ quality: 1 }).toBuffer();
237
+ };
238
+
239
+ /**
240
+ * Process one image at a ladder position: pass through when it already fits the
241
+ * position's caps (Anthropic-native format, dims within maxEdge, size within hardCap —
242
+ * this also exempts possibly-animated GIF/WebP from a lossy re-encode; pass-through is
243
+ * additionally VALIDATED with a full decode once, cached), otherwise walk positions
244
+ * downward encoding until a hard cap is met; terminal accepts measured size.
245
+ * `mediaType` must be the ORIGINAL source media type (cache keys include it — C-gate
246
+ * round 1, blocker 4 — and pass-through eligibility depends on it).
247
+ */
248
+ export async function processAt(
249
+ b64: string,
250
+ startPos: number,
251
+ mediaType: string,
252
+ encode: EncodeFn,
253
+ validate: ValidateFn,
254
+ ): Promise<ProcessResult & { pos: number }> {
255
+ const dims = sniffImageDimensions(b64);
256
+ const hash = Bun.hash(b64).toString(36);
257
+ let input: Uint8Array;
258
+ try {
259
+ input = Uint8Array.from(Buffer.from(b64, "base64"));
260
+ } catch {
261
+ return { kind: "failed", pos: startPos };
262
+ }
263
+ for (let pos = startPos; pos <= TERMINAL_POS; pos++) {
264
+ const spec = TIER_SPECS[pos];
265
+ const key = `${hash}:${mediaType}:${pos}`;
266
+ const cached = cacheGet(key);
267
+ if (cached === "pass") return { kind: "pass", b64Length: b64.length, pos };
268
+ if (cached === "miss") continue; // known cap miss: skip to the next position
269
+ if (cached) return { kind: "encoded", data: cached.data, mediaType: cached.mediaType, pos };
270
+
271
+ const fitsDims = dims !== null && dims.width <= spec.maxEdge && dims.height <= spec.maxEdge;
272
+ if (PASSTHROUGH_MEDIA.has(mediaType) && fitsDims && b64.length <= spec.hardCap) {
273
+ try {
274
+ await validate(input); // sniffable-but-truncated data must not ride pass-through
275
+ } catch {
276
+ return { kind: "failed", pos };
277
+ }
278
+ cachePut(key, "pass");
279
+ return { kind: "pass", b64Length: b64.length, pos };
280
+ }
281
+
282
+ let last: { data: string; mediaType: string } | null = null;
283
+ try {
284
+ for (const quality of spec.qualities) {
285
+ encodeCalls++;
286
+ last = await encode(input, spec, quality);
287
+ if (last.data.length <= spec.hardCap) {
288
+ cachePut(key, last);
289
+ return { kind: "encoded", data: last.data, mediaType: last.mediaType, pos };
290
+ }
291
+ }
292
+ } catch {
293
+ // Decode/encode failure: corrupt or unsupported payload (audit round 2, blocker 2).
294
+ return { kind: "failed", pos };
295
+ }
296
+ if (pos === TERMINAL_POS && last) {
297
+ cachePut(key, last);
298
+ return { kind: "encoded", data: last.data, mediaType: last.mediaType, pos };
299
+ }
300
+ // Hard cap missed at this position — remember the miss, continue down the ladder.
301
+ cachePut(key, "miss");
302
+ }
303
+ return { kind: "failed", pos: TERMINAL_POS };
304
+ }
@@ -20,245 +20,21 @@ 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";
24
23
 
25
- /** One ladder position: dimension cap, JPEG quality attempts, per-image base64 cap. */
26
- export interface TierSpec {
27
- maxEdge: number;
28
- qualities: number[];
29
- /** Hard per-image base64-length cap at this position; Infinity = terminal (measured size accepted). */
30
- hardCap: number;
31
- }
32
-
33
- const KiB = 1024;
34
- const MiB = 1024 * 1024;
35
-
36
- /**
37
- * Ladder positions 0-5. 0-2 are the age-assigned tiers; 3-5 are demotion floor steps.
38
- * Terminal (last) accepts its measured output so the aggregate loop always terminates
39
- * (audit round 2, blocker 1).
40
- */
41
- export const TIER_SPECS: TierSpec[] = [
42
- { maxEdge: 2000, qualities: [80, 60, 40, 30], hardCap: 2 * MiB },
43
- { maxEdge: 1024, qualities: [70, 50], hardCap: 512 * KiB },
44
- { maxEdge: 700, qualities: [60, 40], hardCap: 192 * KiB },
45
- { maxEdge: 500, qualities: [40], hardCap: 100 * KiB },
46
- { maxEdge: 400, qualities: [30], hardCap: 100 * KiB },
47
- { maxEdge: 320, qualities: [25], hardCap: Infinity },
48
- ];
49
- const TERMINAL_POS = TIER_SPECS.length - 1;
50
-
51
- /** Newest 6 images ride tier 0, the next 14 tier 1, the rest tier 2 (020 tier table). */
52
- const TIER0_COUNT = 6;
53
- const TIER1_COUNT = 14;
24
+ export type { TierSpec, NormalizeOptions, EncodeFn, ValidateFn } from "./anthropic-image-codec";
25
+ export { TIER_SPECS, MAX_INPUT_BASE64_LENGTH, IMAGE_NORMALIZE_CONCURRENCY, MAX_INPUT_PIXELS } from "./anthropic-image-codec";
26
+ export { IMAGE_NORMALIZE_CACHE_MAX_BYTES } from "./anthropic-image-codec";
27
+ export { getNormalizeStatsForTests, resetNormalizeStateForTests, setNormalizeCacheLimitsForTests } from "./anthropic-image-codec";
28
+ export { anthropicImageNormalizeRetainedStoreSnapshot, evictOldestAnthropicImageNormalizeForBudget } from "./anthropic-image-codec";
54
29
 
55
- /** Decode-bomb guards: refuse to decode absurd inputs (020 guards; "extreme values excluded"). */
56
- export const MAX_INPUT_BASE64_LENGTH = 64 * MiB;
57
-
58
- /**
59
- * First-pass worker-pool width. Memory-bound, not CPU-bound: each in-flight item can
60
- * hold a decoded bitmap, so this bounds peak memory to ~4 decoded images while still
61
- * overlapping I/O and native-encode threadpool work. Fixed on purpose — a config knob
62
- * would widen the adapter contract with no demonstrated need.
63
- */
64
- export const IMAGE_NORMALIZE_CONCURRENCY = 4;
65
- export const MAX_INPUT_PIXELS = 100_000_000;
30
+ import { bunImageEncode, bunImageValidate, processAt, TERMINAL_POS, TIER0_COUNT, TIER1_COUNT } from "./anthropic-image-codec";
31
+ import { IMAGE_NORMALIZE_CONCURRENCY, MAX_INPUT_BASE64_LENGTH, MAX_INPUT_PIXELS } from "./anthropic-image-codec";
32
+ import type { NormalizeOptions } from "./anthropic-image-codec";
66
33
 
67
34
  const UNDECODABLE_TEXT = "[image omitted: undecodable or corrupt image data]";
68
35
  const BOMB_TEXT = "[image omitted: image too large to process safely]";
69
36
  const OVERFLOW_DROP_TEXT = "[image omitted: total image payload exceeded the provider request budget; older images were dropped]";
70
37
 
71
- /** Formats Anthropic accepts as-is; anything else must be transcoded or dropped. */
72
- const PASSTHROUGH_MEDIA = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
73
-
74
- export interface NormalizeOptions {
75
- /** Shift every image's starting ladder position down (413 retry tightening; 030). */
76
- tierBias?: number;
77
- /** Test seam: replaces the Bun.Image encode path (audit round 1, blocker 6). */
78
- encode?: EncodeFn;
79
- /** Test seam: replaces the pass-through decode validation (C-gate round 1, blocker 1). */
80
- validate?: ValidateFn;
81
- }
82
-
83
- export type EncodeFn = (
84
- input: Uint8Array,
85
- spec: TierSpec,
86
- quality: number,
87
- ) => Promise<{ data: string; mediaType: string }>;
88
-
89
- /** Proves the payload fully decodes; must throw for corrupt/truncated data. */
90
- export type ValidateFn = (input: Uint8Array) => Promise<void>;
91
-
92
- type ProcessResult =
93
- | { kind: "pass"; b64Length: number }
94
- | { kind: "encoded"; data: string; mediaType: string }
95
- | { kind: "failed" };
96
-
97
- /**
98
- * Byte-weighted LRU over normalized outputs (audit round 1, blocker 2): aggregate cap,
99
- * not entry count. Entries are immutable snapshots — demotions write NEW tier-suffixed
100
- * keys, never mutate stored values.
101
- */
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;
105
- // "pass" = validated pass-through; "miss" = this position's ladder cannot meet its hard
106
- // cap for these bytes (skip straight to the next position — C-gate round 2, blocker 1).
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 };
127
- let cacheBytes = 0;
128
- let cacheMetadataBytes = 0;
129
- let cacheSentinelEntries = 0;
130
- let encodeCalls = 0;
131
-
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;
160
- const existing = cache.get(key);
161
- if (existing !== undefined) {
162
- deleteCacheEntry(key); // re-insert refreshes recency and prevents double-count on concurrent misses
163
- }
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;
167
- }
168
- cache.set(key, next);
169
- cacheBytes += next.sizeBytes;
170
- cacheMetadataBytes += next.metadataBytes;
171
- if (typeof value === "string") cacheSentinelEntries++;
172
- enforceAppOwnedMemoryBudget();
173
- return true;
174
- }
175
-
176
- /** Read a cache entry, refreshing its recency (true LRU, C-gate round 1 blocker 5). */
177
- function cacheGet(key: string): CacheValue | undefined {
178
- const entry = cache.get(key);
179
- if (entry !== undefined) {
180
- cache.delete(key);
181
- entry.storedAt = Date.now();
182
- cache.set(key, entry);
183
- }
184
- return entry?.value;
185
- }
186
-
187
- /** Test hooks: encoder-invocation counter + cache reset (no production caller). */
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
- };
204
- }
205
- export function resetNormalizeStateForTests(): void {
206
- cache.clear();
207
- cacheBytes = 0;
208
- cacheMetadataBytes = 0;
209
- cacheSentinelEntries = 0;
210
- encodeCalls = 0;
211
- }
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
-
239
- /** Default encoder: Bun.Image resize-to-fit + JPEG at the given quality. */
240
- const bunImageEncode: EncodeFn = async (input, spec, quality) => {
241
- const image = new Bun.Image(input);
242
- const meta = await image.metadata();
243
- const w = typeof meta.width === "number" ? meta.width : 0;
244
- const h = typeof meta.height === "number" ? meta.height : 0;
245
- let pipeline = new Bun.Image(input);
246
- if (w > spec.maxEdge || h > spec.maxEdge) {
247
- const scale = spec.maxEdge / Math.max(w, h);
248
- pipeline = pipeline.resize(Math.max(1, Math.round(w * scale)), Math.max(1, Math.round(h * scale)));
249
- }
250
- const out = await pipeline.jpeg({ quality }).toBuffer();
251
- return { data: Buffer.from(out).toString("base64"), mediaType: "image/jpeg" };
252
- };
253
-
254
- /**
255
- * Default pass-through validation: force a full decode (resize forces pixel decoding, a
256
- * header-only metadata read does not). A sniffable-but-truncated payload must throw here
257
- * instead of riding pass-through to an Anthropic 400 (C-gate round 1, blocker 1).
258
- */
259
- const bunImageValidate: ValidateFn = async input => {
260
- await new Bun.Image(input).resize(1, 1).jpeg({ quality: 1 }).toBuffer();
261
- };
262
38
 
263
39
  function mediaTypeOf(ref: ImageBlockRef): string {
264
40
  const block = ref.container[ref.index] as { source?: { media_type?: unknown } } | undefined;
@@ -279,72 +55,6 @@ function initialPosition(newestFirstIndex: number, bias: number): number {
279
55
  return Math.min(base + Math.max(0, bias), TERMINAL_POS);
280
56
  }
281
57
 
282
- /**
283
- * Process one image at a ladder position: pass through when it already fits the
284
- * position's caps (Anthropic-native format, dims within maxEdge, size within hardCap —
285
- * this also exempts possibly-animated GIF/WebP from a lossy re-encode; pass-through is
286
- * additionally VALIDATED with a full decode once, cached), otherwise walk positions
287
- * downward encoding until a hard cap is met; terminal accepts measured size.
288
- * `mediaType` must be the ORIGINAL source media type (cache keys include it — C-gate
289
- * round 1, blocker 4 — and pass-through eligibility depends on it).
290
- */
291
- async function processAt(
292
- b64: string,
293
- startPos: number,
294
- mediaType: string,
295
- encode: EncodeFn,
296
- validate: ValidateFn,
297
- ): Promise<ProcessResult & { pos: number }> {
298
- const dims = sniffImageDimensions(b64);
299
- const hash = Bun.hash(b64).toString(36);
300
- let input: Uint8Array;
301
- try {
302
- input = Uint8Array.from(Buffer.from(b64, "base64"));
303
- } catch {
304
- return { kind: "failed", pos: startPos };
305
- }
306
- for (let pos = startPos; pos <= TERMINAL_POS; pos++) {
307
- const spec = TIER_SPECS[pos];
308
- const key = `${hash}:${mediaType}:${pos}`;
309
- const cached = cacheGet(key);
310
- if (cached === "pass") return { kind: "pass", b64Length: b64.length, pos };
311
- if (cached === "miss") continue; // known cap miss: skip to the next position
312
- if (cached) return { kind: "encoded", data: cached.data, mediaType: cached.mediaType, pos };
313
-
314
- const fitsDims = dims !== null && dims.width <= spec.maxEdge && dims.height <= spec.maxEdge;
315
- if (PASSTHROUGH_MEDIA.has(mediaType) && fitsDims && b64.length <= spec.hardCap) {
316
- try {
317
- await validate(input); // sniffable-but-truncated data must not ride pass-through
318
- } catch {
319
- return { kind: "failed", pos };
320
- }
321
- cachePut(key, "pass");
322
- return { kind: "pass", b64Length: b64.length, pos };
323
- }
324
-
325
- let last: { data: string; mediaType: string } | null = null;
326
- try {
327
- for (const quality of spec.qualities) {
328
- encodeCalls++;
329
- last = await encode(input, spec, quality);
330
- if (last.data.length <= spec.hardCap) {
331
- cachePut(key, last);
332
- return { kind: "encoded", data: last.data, mediaType: last.mediaType, pos };
333
- }
334
- }
335
- } catch {
336
- // Decode/encode failure: corrupt or unsupported payload (audit round 2, blocker 2).
337
- return { kind: "failed", pos };
338
- }
339
- if (pos === TERMINAL_POS && last) {
340
- cachePut(key, last);
341
- return { kind: "encoded", data: last.data, mediaType: last.mediaType, pos };
342
- }
343
- // Hard cap missed at this position — remember the miss, continue down the ladder.
344
- cachePut(key, "miss");
345
- }
346
- return { kind: "failed", pos: TERMINAL_POS };
347
- }
348
58
 
349
59
  /**
350
60
  * Wire-neutral image handle (devlog/260714_image_normalization_pipeline/050): the core