From 7f94c524bd8ac5cc62f06b4551c7e38a6ca327d1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 03:47:07 +0900 Subject: [PATCH] fix(devin): preserve the catalog image-support flag as a tri-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseCatalogBuffer had arms for ClientModelConfig fields 1, 4, 18 and 22 and no default, so field 5 (supports_images) was dropped by omission. Carry it on ModelCatalogEntry as an optional boolean: a present true asserts text+image support, a present false asserts text-only, and an omitted field stays unknown. It deliberately does not copy the disabled pattern, which defaults to false — collapsing unknown into text-only was the #1796 regression, and antigravity-models.ts already implements the same tri-state for its discovered catalog. The header schema comment gains the #5 row and the #18 row it never listed, and its verification claim now says which fields came from the bundled extension.js, which from a live catalog dump, and which from the public WindsurfAPI documentation. The owning structure doc records the catalog pre-flight contract. Propagation of the flag to the client catalog is a separate change. --- src/adapters/devin/cloud-direct/catalog.ts | 23 ++++++++++++++-- structure/adapters/registry.md | 7 +++++ tests/providers/devin-adapter.test.ts | 32 ++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/adapters/devin/cloud-direct/catalog.ts b/src/adapters/devin/cloud-direct/catalog.ts index f7638d1660..4dcb42b082 100644 --- a/src/adapters/devin/cloud-direct/catalog.ts +++ b/src/adapters/devin/cloud-direct/catalog.ts @@ -21,8 +21,10 @@ * drift) we silently fall back to the chat path so a transient catalog * outage can't take chat down with it. * - * Schema (verified against the bundled `extension.js`, - * `exa.codeium_common_pb.ClientModelConfig`): + * Schema (#1/#4/#22 verified against the bundled `extension.js`, + * `exa.codeium_common_pb.ClientModelConfig`; #18 identified from a live + * catalog dump against vendor-known windows; #5 corroborated against the + * public WindsurfAPI `ClientModelConfig` documentation): * * GetCascadeModelConfigsResponse { * #1 client_model_configs: repeated ClientModelConfig @@ -30,6 +32,8 @@ * ClientModelConfig { * #1 label string * #4 disabled bool ← the gate this module reads + * #5 supports_images bool ← tri-state: absent stays unknown + * #18 max_input_tokens varint ← per-account context window * #22 model_uid string ← what `GetChatMessage` accepts * } * @@ -78,6 +82,15 @@ export interface ModelCatalogEntry { * degrades: the caller keeps its static fallback instead of reporting zero. */ contextWindow?: number; + /** + * Image-input support from `ClientModelConfig` field #5, kept as a + * tri-state: a present `true` asserts text+image support, a present + * `false` asserts text-only, and an OMITTED field stays `undefined` + * (unknown). Deliberately unlike `disabled`, which defaults to false — + * collapsing "never asserted" into "text-only" was the #1796 regression + * (see src/providers/antigravity-models.ts). + */ + supportsImages?: boolean; } export interface CacheEntry { @@ -113,12 +126,17 @@ export function parseCatalogBuffer(buf: Buffer, apiKey: string, host: string): C let modelUid = ''; let disabled = false; let contextWindow = 0; + let supportsImages: boolean | undefined; for (const sf of iterFields(f.value as Buffer)) { if (sf.num === 1 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { label = (sf.value as Buffer).toString('utf8'); } else if (sf.num === 4 && sf.wire === 0) { // #4 = disabled (bool, varint 0/1) disabled = sf.value === 1n; + } else if (sf.num === 5 && sf.wire === 0) { + // #5 = supportsImages (bool, varint 0/1). Absent stays unknown — see + // ModelCatalogEntry; do not default it like disabled. + supportsImages = sf.value === 1n; } else if (sf.num === 18 && sf.wire === 0) { // #18 = max input tokens. Identified by dumping a live catalog and // reading the varints back against models whose windows are known from @@ -135,6 +153,7 @@ export function parseCatalogBuffer(buf: Buffer, apiKey: string, host: string): C label: label || modelUid, disabled, ...(contextWindow > 0 ? { contextWindow } : {}), + ...(supportsImages !== undefined ? { supportsImages } : {}), }); } } diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 1952e3615f..9bc0569599 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -51,6 +51,13 @@ Some adapters share another adapter's routed-tool semantics while retaining inde `projectDevinCliAuthMode` rewrites any saved row that still names the retired adapter id, alongside the merge migration that retires the `devin-cli` provider id itself. + Before spending a chat roundtrip the adapter runs a catalog pre-flight: + `src/adapters/devin/cloud-direct/catalog.ts` fetches `GetCascadeModelConfigs` and preserves + `ClientModelConfig` field #4 as the per-account disabled gate, field #18 as the per-account + context window, and field #5 as an optional `supportsImages` tri-state — a present value + asserts image support or its absence, while an omitted field stays unknown (the #1796 + precedent). + The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral. Codex Spark retirement removes model-specific exceptions from the Responses adapter, without diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 8a339163fe..743887f9dc 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -322,6 +322,38 @@ describe("devin adapter", () => { expect(catalog.byUid.get("mystery-model")?.contextWindow).toBeUndefined(); }); + test("the catalog parser preserves image support as a tri-state", () => { + // ClientModelConfig #5 is supports_images. encodeVarintField(5, 0) emits + // real bytes ([0x28, 0x00]), so the false case is not an omission case — + // and a genuinely absent field must stay unknown rather than collapse to + // text-only (#1796). + const vision = Buffer.concat([ + encodeString(1, "Vision Model"), + encodeVarintField(5, 1), + encodeString(22, "vision-model"), + ]); + const textOnly = Buffer.concat([ + encodeString(1, "Text Model"), + encodeVarintField(5, 0), + encodeString(22, "text-model"), + ]); + const unknown = Buffer.concat([ + encodeString(1, "Unknown Model"), + encodeString(22, "unknown-model"), + ]); + const catalog = parseCatalogBuffer( + Buffer.concat([encodeMessage(1, vision), encodeMessage(1, textOnly), encodeMessage(1, unknown)]), + "key", + "https://server.codeium.com", + ); + expect(catalog.byUid.get("vision-model")?.supportsImages).toBe(true); + // toBe(false), not toBeFalsy: a present 0 asserts text-only. + expect(catalog.byUid.get("text-model")?.supportsImages).toBe(false); + // The entry must exist before its field can be asserted absent. + expect(catalog.byUid.get("unknown-model")).toBeDefined(); + expect(catalog.byUid.get("unknown-model")?.supportsImages).toBeUndefined(); + }); + test("the degraded-mode windows match what Cognition serves", () => { // This table was wrong for nine of its eleven rows because it had been // copied from each model's ORIGINAL vendor rather than measured against