@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.
- package/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-Djowl68T.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +13 -3
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +216 -26
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +22 -10
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +21 -6
- package/src/cli/capabilities.ts +13 -2
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +170 -23
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +30 -4
- package/src/codex/catalog/metadata.ts +39 -9
- package/src/codex/catalog/native-models.ts +53 -12
- package/src/codex/catalog/parsing.ts +119 -5
- package/src/codex/catalog/provider-fetch.ts +150 -20
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +1 -1
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +3 -28
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +454 -166
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +127 -26
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +535 -104
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +107 -21
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +38 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +476 -57
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-BU1tE0sr.js +0 -112
- package/gui/dist/assets/index-DL9-iS6J.css +0 -1
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -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
|
-
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|