From 266df98f383c1bbefd26d10390db8de545c7d113 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Tue, 8 Sep 2026 17:58:38 +0400 Subject: [PATCH] fix(catalog): sidecar-covered combo members keep image advertising Complete discovery rows reach resolveComboCatalogMember un-hinted, so a text-only vision-sidecar consumer collapsed the whole combo to input_modalities text at derivation. The Codex app gates attachments client-side on input_modalities, so combos containing a no-vision member (e.g. planners with DeepSeek targets) blocked pasted images before the sidecar could run, even though the same member advertised image on its own provider row. Mirror the direct-row sidecar advertisement inside combo member resolution so derivation intersects the modalities the runtime actually serves. --- src/codex/catalog/provider-fetch.ts | 15 ++++- .../catalog-vision-sidecar-modalities.test.ts | 67 ++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b90e0b12cf..527a940049 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1016,7 +1016,19 @@ export function resolveComboCatalogMember( && fallback?.inputModalities !== undefined; const addReasoning = member.reasoningEfforts === undefined && fallback?.reasoningEfforts !== undefined; - if (!addMaxInput && !addMaxOutput && !adjustAutoCompact && !addModalities && !addReasoning) return member; + // A sidecar-covered member is advertised image-capable on its own provider row + // (applyProviderConfigHints), but complete discovery rows reach this resolver + // un-hinted, so the combo intersection saw the raw text-only declaration and one + // blind member collapsed the whole combo to text-only — the Codex app then blocks + // attachments client-side before the sidecar can run. Mirror the direct-row + // advertisement here so derivation intersects the modalities the runtime serves. + const modalitiesAfterFallback = addModalities + ? [...fallback!.inputModalities!] + : member.inputModalities; + const addSidecarImage = prov !== undefined + && isModelVisionSidecarConsumer(prov, member.id) + && !(modalitiesAfterFallback ?? ["text"]).includes("image"); + if (!addMaxInput && !addMaxOutput && !adjustAutoCompact && !addModalities && !addReasoning && !addSidecarImage) return member; return { ...member, // Never claim a larger input budget than the window, and prefer the model's own @@ -1026,6 +1038,7 @@ export function resolveComboCatalogMember( ...(adjustAutoCompact && autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), ...(addModalities ? { inputModalities: [...fallback!.inputModalities!] } : {}), ...(addReasoning ? { reasoningEfforts: [...fallback!.reasoningEfforts!] } : {}), + ...(addSidecarImage ? { inputModalities: [...(modalitiesAfterFallback ?? ["text"]), "image"] } : {}), }; }; diff --git a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts index 3e51ac0d01..cbdae873e8 100644 --- a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts +++ b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { applyProviderConfigHints, gatherRoutedModels } from "../../src/codex/catalog"; +import { applyProviderConfigHints, gatherRoutedModels, resolveComboCatalogMember } from "../../src/codex/catalog"; import { clearModelCache } from "../../src/codex/model-cache"; import type { OcxProviderConfig } from "../../src/types"; import { deriveComboCatalogModel } from "../../src/codex/catalog"; @@ -390,6 +390,71 @@ describe("vision-capable provider models feed combo modalities", () => { ); expect(derived?.inputModalities).toEqual(["text", "image"]); }); + + test("a sidecar-covered discovery row no longer collapses combo image advertising", () => { + // The real call site resolves members through resolveComboCatalogMember, and complete + // discovery rows arrive WITHOUT the config hint that rewrites direct provider rows. + // The /planners combo hit exactly this: sidecar-covered DeepSeek rows kept their raw + // text-only modalities at derivation, so combo/planners advertised text-only and the + // Codex app blocked pasted images before the sidecar could run. + const blind = { + provider: "azu-lab1", + id: "DeepSeek-V4-Pro", + contextWindow: 128_000, + inputModalities: ["text"], + } as CatalogModel; + const covered = new Map([ + ["azu-lab1", { + adapter: "openai-chat", + baseUrl: "https://azu-lab1.example/v1", + noVisionModels: ["DeepSeek-V4-Pro"], + } as OcxProviderConfig], + ]); + const resolved = resolveComboCatalogMember( + { provider: "azu-lab1", model: "DeepSeek-V4-Pro" }, + new Map([["azu-lab1/DeepSeek-V4-Pro", blind]]), + covered, + ); + expect(resolved?.inputModalities).toEqual(["text", "image"]); + + const targets = [ + { provider: "azu-lab1", model: "DeepSeek-V4-Pro" }, + { provider: "azu-lab2", model: "gpt-5.6-terra" }, + ]; + const members = [ + resolved!, + { + provider: "azu-lab2", + id: "gpt-5.6-terra", + contextWindow: 200_000, + inputModalities: ["text", "image"], + } as CatalogModel, + ]; + const derived = deriveComboCatalogModel( + "planners", + { targets, defaultEffort: "high" } as never, + members, + ); + expect(derived?.inputModalities).toEqual(["text", "image"]); + + // combo.imageInput: "disabled" still strips image from the advertised modalities. + const disabled = deriveComboCatalogModel( + "planners", + { targets, defaultEffort: "high", imageInput: "disabled" } as never, + members, + ); + expect(disabled?.inputModalities).toEqual(["text"]); + + // Providers without sidecar coverage stay untouched (identity preserved). + expect(resolveComboCatalogMember( + { provider: "azu-lab1", model: "DeepSeek-V4-Pro" }, + new Map([["azu-lab1/DeepSeek-V4-Pro", blind]]), + new Map([["azu-lab1", { + adapter: "openai-chat", + baseUrl: "https://azu-lab1.example/v1", + } as OcxProviderConfig]]), + )).toBe(blind); + }); }); describe("Cursor native vs sidecar vision registry", () => {