From f700c56172d79f1222690d7a1ccfb9d6cc4c53bf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:22:17 +0000 Subject: [PATCH 1/2] test(openai-chat): declare role acceptance in suites that assert the forwarded role (#5334 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5334 made the developer wire role tri-state: an undeclared destination folds it to system. Two suites asserting role:"developer" on the Chat wire were missed because they are about tool-result repair ordering and document parts, not role selection — declare the destination, per the convention the change established. Verified: both files fail on dev@600075d2 with system-for-developer wire roles and pass with the declaration. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts | 4 ++++ tests/responses/chat-inline-document-bytes.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c21023..075e3c33186 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,10 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; this suite is about tool-result repair ordering, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa05788..a718c1c376a 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,10 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; the document test asserts the role a turn keeps, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic", From 2554e18b1d3d1f5d94335af927f7a9bf346506cd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 21 Sep 2026 09:26:15 +0900 Subject: [PATCH 2/2] fix(anthropic): bound retained image position keys --- src/adapters/anthropic-image-codec.ts | 68 +++++++++++++------ structure/transports/inventory.md | 2 +- .../anthropic-image-normalize.test.ts | 20 +++++- 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/adapters/anthropic-image-codec.ts b/src/adapters/anthropic-image-codec.ts index c0c9ceb6c57..739eb33cc48 100644 --- a/src/adapters/anthropic-image-codec.ts +++ b/src/adapters/anthropic-image-codec.ts @@ -116,16 +116,32 @@ let encodeCalls = 0; * rank by one and can push it across a tier boundary — re-encoding it to different * bytes and busting Anthropic's prompt prefix cache for the whole history. Pinning the * start position to the image's own identity keeps already-emitted bytes stable across - * appends. Keys are the encode cache's identity minus the position suffix - * (`${hash}:${mediaType}`, see processAt). Entry-count cap with LRU eviction: a - * value is one small number, so a count bound is a byte bound (~4096 * ~50B worst - * case, far under the app-owned memory budget's headroom). + * appends. Keys are fixed-size digests of the bytes and canonical media type, so + * caller-controlled metadata cannot make the retained identity arbitrarily large. + * The store participates in the shared retained-memory budget as well as its own + * entry-count cap. */ const POSITION_STORE_MAX_ENTRIES = 4_096; -const emittedPositions = new Map(); +const MAX_CANONICAL_MEDIA_TYPE_LENGTH = 127; +const MEDIA_TYPE_PATTERN = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/; +interface PositionEntry { position: number; sizeBytes: number; storedAt: number } +const emittedPositions = new Map(); +let positionBytes = 0; function positionKey(b64: string, mediaType: string): string { - return `${Bun.hash(b64).toString(36)}:${mediaType}`; + const normalized = mediaType.trim().toLowerCase(); + const canonical = normalized.length <= MAX_CANONICAL_MEDIA_TYPE_LENGTH && MEDIA_TYPE_PATTERN.test(normalized) + ? normalized + : "application/octet-stream"; + return new Bun.CryptoHasher("sha256").update(b64).update("\0").update(canonical).digest("hex"); +} + +function deletePositionEntry(key: string): number { + const entry = emittedPositions.get(key); + if (!entry) return 0; + emittedPositions.delete(key); + positionBytes -= entry.sizeBytes; + return entry.sizeBytes; } /** @@ -134,12 +150,13 @@ function positionKey(b64: string, mediaType: string): string { */ export function recordedEmittedPosition(b64: string, mediaType: string): number | undefined { const key = positionKey(b64, mediaType); - const pos = emittedPositions.get(key); - if (pos !== undefined) { + const entry = emittedPositions.get(key); + if (entry !== undefined) { emittedPositions.delete(key); - emittedPositions.set(key, pos); + entry.storedAt = Date.now(); + emittedPositions.set(key, entry); } - return pos; + return entry?.position; } /** @@ -153,15 +170,17 @@ export function recordEmittedPosition(b64: string, mediaType: string, pos: numbe const key = positionKey(b64, mediaType); const existing = emittedPositions.get(key); if (existing !== undefined) { - emittedPositions.delete(key); - pos = Math.max(existing, pos); + deletePositionEntry(key); + pos = Math.max(existing.position, pos); } while (emittedPositions.size + 1 > POSITION_STORE_MAX_ENTRIES) { const oldest = emittedPositions.keys().next().value; if (oldest === undefined) break; - emittedPositions.delete(oldest); + deletePositionEntry(oldest); } - emittedPositions.set(key, pos); + const sizeBytes = cacheEncoder.encode(key).byteLength + 16; + emittedPositions.set(key, { position: pos, sizeBytes, storedAt: Date.now() }); + positionBytes += sizeBytes; enforceAppOwnedMemoryBudget(); } @@ -241,6 +260,7 @@ export function getNormalizeStatsForTests(): { export function resetNormalizeStateForTests(): void { cache.clear(); emittedPositions.clear(); + positionBytes = 0; cacheBytes = 0; cacheMetadataBytes = 0; cacheSentinelEntries = 0; @@ -259,18 +279,26 @@ export function anthropicImageNormalizeRetainedStoreSnapshot(): { pinnedBytes: number; oldestAt: number | null; } { + const oldestAt = Math.min( + cache.values().next().value?.storedAt ?? Infinity, + emittedPositions.values().next().value?.storedAt ?? Infinity, + ); return { - count: cache.size, - bytes: cacheBytes, - evictableBytes: cacheBytes, + count: cache.size + emittedPositions.size, + bytes: cacheBytes + positionBytes, + evictableBytes: cacheBytes + positionBytes, pinnedBytes: 0, - oldestAt: cache.values().next().value?.storedAt ?? null, + oldestAt: Number.isFinite(oldestAt) ? oldestAt : null, }; } export function evictOldestAnthropicImageNormalizeForBudget(): number { - const oldest = cache.keys().next().value; - return oldest === undefined ? 0 : deleteCacheEntry(oldest); + const cacheOldest = cache.entries().next().value as [string, CacheEntry] | undefined; + const positionOldest = emittedPositions.entries().next().value as [string, PositionEntry] | undefined; + if (!positionOldest || (cacheOldest && cacheOldest[1].storedAt <= positionOldest[1].storedAt)) { + return cacheOldest ? deleteCacheEntry(cacheOldest[0]) : 0; + } + return deletePositionEntry(positionOldest[0]); } /** Default encoder: Bun.Image resize-to-fit + JPEG at the given quality. */ diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ed5b26d601e..29d42271fab 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -32,7 +32,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Meta Muse Responses tool names | `src/responses/muse-tool-name-alias.ts`, `src/adapters/openai-responses.ts` | `api.meta.ai` only: function names over 64 characters or containing characters outside `[a-zA-Z0-9_-]` become collision-safe wire aliases and are restored before the client sees them. | | Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/adapters/google-wire-shape.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses shared abort/deadline helpers, upstream error normalization, and policy-aware wire-body repair: strict initial schema loss sends nothing, while strict repair withholding returns the original 400 without a changed send. The final compiler produces the [content-free tool-schema loss contract](../providers/google.md#google-tool-schema-loss-reporting). `google-wire-shape.ts` remains diagnostic-only. | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | -| Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. An image's ladder position is pinned to its own identity (content hash + media type) rather than recomputed from recency each request (#4532); appending a newer image therefore cannot demote and re-encode older images and bust Anthropic's prompt prefix cache. Unseen images still take the age-tier pyramid's first position, the total byte budget still binds, and a 413 `tierBias` retry still applies. Recorded positions only move down the ladder, so the store is monotonic. | +| Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. An image's ladder position is pinned to its own fixed-size digest of content and canonical media type rather than recomputed from recency each request (#4532); appending a newer image therefore cannot demote and re-encode older images and bust Anthropic's prompt prefix cache. The position store is byte-accounted with the normalization cache. Unseen images still take the age-tier pyramid's first position, the total byte budget still binds, and a 413 `tierBias` retry still applies. Recorded positions only move down the ladder, so the store is monotonic. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | | Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | diff --git a/tests/adapters/anthropic/anthropic-image-normalize.test.ts b/tests/adapters/anthropic/anthropic-image-normalize.test.ts index 99decc4f483..05958bd3d18 100644 --- a/tests/adapters/anthropic/anthropic-image-normalize.test.ts +++ b/tests/adapters/anthropic/anthropic-image-normalize.test.ts @@ -17,7 +17,10 @@ import { sniffImageDimensions, TOTAL_IMAGE_BASE64_BUDGET, } from "../../../src/adapters/anthropic-image-guard"; -import { TIER0_COUNT } from "../../../src/adapters/anthropic-image-codec"; +import { + recordEmittedPosition, + TIER0_COUNT, +} from "../../../src/adapters/anthropic-image-codec"; /** 1x1 red PNG — the smallest real, fully-decodable fixture. */ const ONE_PX_PNG = @@ -127,7 +130,7 @@ describe("bounded normalization cache accounting", () => { await Promise.all([first, second]); const stats = getNormalizeStatsForTests(); expect(stats.cacheEntries).toBe(1); - expect(stats.cacheBytes).toBe(anthropicImageNormalizeRetainedStoreSnapshot().bytes); + expect(anthropicImageNormalizeRetainedStoreSnapshot().bytes).toBeGreaterThan(stats.cacheBytes); expect(stats.cacheBytes).toBeGreaterThan(1_000); }); @@ -165,6 +168,19 @@ describe("bounded normalization cache accounting", () => { expect(released).toBeGreaterThanOrEqual(getNormalizeStatsForTests().metadataBytes / Math.max(1, before.count)); expect(anthropicImageNormalizeRetainedStoreSnapshot().bytes).toBe(before.bytes - released); }); + + test("position identities retain a fixed amount for caller-controlled media types", () => { + const hugeMediaType = `image/${"x".repeat(1024 * 1024)}`; + recordEmittedPosition(ONE_PX_PNG, hugeMediaType, 1); + const first = anthropicImageNormalizeRetainedStoreSnapshot(); + expect(first.count).toBe(1); + expect(first.bytes).toBeLessThan(256); + + recordEmittedPosition(ONE_PX_PNG, `${hugeMediaType}y`, 2); + const second = anthropicImageNormalizeRetainedStoreSnapshot(); + expect(second.count).toBe(first.count); + expect(second.bytes).toBe(first.bytes); + }); }); describe("normalizeAnthropicImages — real Bun.Image path", () => {