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/8] 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 41a61c2102..075e3c3318 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 dd88fa0578..a718c1c376 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 a2aadb746ab1a66822757aaaa828faa99ab814f9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 21 Sep 2026 09:29:15 +0900 Subject: [PATCH 2/8] fix(vision): bind metadata to provider transport --- src/vision/eligibility.ts | 6 ++++++ structure/runtime.md | 2 +- tests/vision/vision-eligibility.test.ts | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index d772c8f976..4dd9fe70ae 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -26,6 +26,7 @@ import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../gen import { nativeInputModalities } from "../codex/catalog/metadata"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import { enrichProviderFromRegistry } from "../providers/derive"; +import { providerMatchesRegistryTransport } from "../providers/registry"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; /** @@ -280,6 +281,11 @@ function modelAcceptsImageInputWithCache( } const fromRow = advertisesImageInput(candidate.inputModalities); if (fromRow !== undefined) return fromRow; + // A preset name is not transport identity. Some fixed key presets intentionally preserve a + // same-named custom destination, so vendor metadata is authoritative only while the configured + // adapter and endpoint still belong to that registry row. Otherwise the capability is unknown + // and request dispatch must preserve the custom destination's image boundary. + if (provider !== undefined && !providerMatchesRegistryTransport(candidate.provider, provider)) return undefined; return metadataImageInput(candidate.provider, candidate.id); } diff --git a/structure/runtime.md b/structure/runtime.md index ec03516b8c..13f74b76ce 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -452,7 +452,7 @@ declare `modelInputModalities: ["text", "image"]` per model for the nine Claude explicit operator overrides; unknown models receive no new declaration. Client eligibility filters and Anthropic image wire handling remain unchanged. -`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. +`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only when its adapter and destination still match the registry transport; a same-named custom destination remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index 28baa98b9a..8fdc301385 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -10,6 +10,7 @@ import { visionEligibleModelOptions, type VisionCandidateModel, } from "../../src/vision/eligibility"; +import { requiresVisionPreprocessing } from "../../src/vision/plan"; const emptyConfig: Pick = { providers: {} }; @@ -189,6 +190,21 @@ describe("vision eligibility core", () => { expect(modelAcceptsImageInput(config, { provider: "runtime", id: "vision" })).toBe(true); }); + test("11d. vendor metadata does not cross a preserved custom destination boundary", () => { + const provider = { + adapter: "openai-responses", + authMode: "key", + baseUrl: "https://operator-gateway.example/v1", + } as const; + const config = configWithProviders({ "zhipu-bigmodel-responses": provider }); + const candidate = { provider: "zhipu-bigmodel-responses", id: "glm-5.3" }; + + // The generated Z.AI bundle calls glm-5.3 text-only, but this preset explicitly permits a + // same-named custom endpoint. Its images must neither leave for a sidecar nor be stripped. + expect(modelAcceptsImageInput(config, candidate)).toBeUndefined(); + expect(requiresVisionPreprocessing(config, provider, candidate.id, candidate.provider)).toBe(false); + }); + test("12. only the selected Anthropic OAuth provider contributes Anthropic options", () => { const config = configWithProviders({ anthropic: { From 3cb3d0ae556b35875296a257b04aecc9d2b2af92 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:21:43 +0000 Subject: [PATCH 3/8] fix(vision): validate transport against the registry entry owning a metadata alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport guard added in a2aadb746 looked the provider name up as a registry id only, so a provider saved under a canonical metadata alias (gemini, anthropic-key, gemini-vertex, ...) failed the lookup and its vendor metadata verdict was discarded — generated text-only models fell back to unknown, bypassing vision preprocessing and picker exclusion. providerMatchesRegistryTransportOrAlias resolves the name to the entry that owns it (by id or declared extraMetadataAliases, case-folded like resolveMetadataProvider) and validates the configured transport against that entry, so an aliased row follows its owner's pinning rule. Co-Authored-By: Epinephrine --- src/providers/registry.ts | 23 +++++++++++++++ src/vision/eligibility.ts | 10 ++++--- structure/runtime.md | 2 +- tests/vision/vision-eligibility.test.ts | 39 +++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 61c08eef7b..39db34c5fe 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -116,6 +116,29 @@ export function providerMatchesRegistryTransport( return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); } +/** + * `providerMatchesRegistryTransport` for a configured name that may be a generated-metadata + * ALIAS rather than a registry id. + * + * A registry row claims extra names through `extraMetadataAliases` (`gemini` for `google`, + * `anthropic-key` for `anthropic`, ...), and `resolveMetadataProvider` resolves those names — + * case-folded, the way saved provider keys arrive — to the row's metadata bundle. A provider + * saved under an alias is owned by the declaring entry, so its transport must be validated + * against that entry; an id-only lookup finds no `gemini` row and would drop a verdict the + * registry still owns. + */ +export function providerMatchesRegistryTransportOrAlias( + name: string, + provider: Pick & Partial>, +): boolean { + const lower = name.toLowerCase(); + const entry = getProviderRegistryEntry(name) + ?? PROVIDER_REGISTRY.find(row => + row.id.toLowerCase() === lower + || (row.extraMetadataAliases ?? []).some(alias => alias.toLowerCase() === lower)); + return entry !== undefined && providerMatchesRegistryTransport(entry.id, provider); +} + /** * Resolve the registry entry a configured provider actually points at, by TRANSPORT * rather than by name. diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 4dd9fe70ae..71e4f4816d 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -26,7 +26,7 @@ import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../gen import { nativeInputModalities } from "../codex/catalog/metadata"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import { enrichProviderFromRegistry } from "../providers/derive"; -import { providerMatchesRegistryTransport } from "../providers/registry"; +import { providerMatchesRegistryTransportOrAlias } from "../providers/registry"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; /** @@ -283,9 +283,11 @@ function modelAcceptsImageInputWithCache( if (fromRow !== undefined) return fromRow; // A preset name is not transport identity. Some fixed key presets intentionally preserve a // same-named custom destination, so vendor metadata is authoritative only while the configured - // adapter and endpoint still belong to that registry row. Otherwise the capability is unknown - // and request dispatch must preserve the custom destination's image boundary. - if (provider !== undefined && !providerMatchesRegistryTransport(candidate.provider, provider)) return undefined; + // adapter and endpoint still belong to the registry row that owns that name — where "owns" + // includes canonical metadata aliases like `gemini` or `anthropic-key`, resolved to the entry + // that declares them. Otherwise the capability is unknown and request dispatch must preserve + // the custom destination's image boundary. + if (provider !== undefined && !providerMatchesRegistryTransportOrAlias(candidate.provider, provider)) return undefined; return metadataImageInput(candidate.provider, candidate.id); } diff --git a/structure/runtime.md b/structure/runtime.md index 13f74b76ce..bd3425bf48 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -452,7 +452,7 @@ declare `modelInputModalities: ["text", "image"]` per model for the nine Claude explicit operator overrides; unknown models receive no new declaration. Client eligibility filters and Anthropic image wire handling remain unchanged. -`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only when its adapter and destination still match the registry transport; a same-named custom destination remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. +`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only while its adapter and destination still match the transport of the registry row owning its name — canonical metadata aliases such as `gemini` resolve to the entry that declares them; a same-named custom destination remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index 8fdc301385..e6bfb41d71 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -205,6 +205,45 @@ describe("vision eligibility core", () => { expect(requiresVisionPreprocessing(config, provider, candidate.id, candidate.provider)).toBe(false); }); + test("11e. a canonical metadata alias keeps the owning registry row's verdict", () => { + // `gemini` is an extraMetadataAlias of the `google` registry row — a provider saved under + // that name resolves to the google bundle but owns no registry id of its own. An id-only + // transport check dropped the verdict entirely, and a generated text-only model silently + // degraded to "unknown" (eligible, no sidecar). + const provider = { + adapter: "google", + authMode: "key", + baseUrl: "https://generativelanguage.googleapis.com", + } as const; + const config = configWithProviders({ gemini: provider }); + const candidate = { provider: "gemini", id: "gemini-live-2.5-flash-preview-native-audio" }; + + expect(modelAcceptsImageInput(config, candidate)).toBe(false); + expect(requiresVisionPreprocessing(config, provider, candidate.id, candidate.provider)).toBe(true); + + // Saved provider keys are case-folded by resolveMetadataProvider; the transport binding + // must fold the same way or a title-cased alias would lose the same verdict. + const folded = configWithProviders({ Gemini: provider }); + expect(modelAcceptsImageInput(folded, { provider: "Gemini", id: candidate.id })).toBe(false); + }); + + test("11f. an alias-named custom destination follows the owning entry's pinning", () => { + // `google` is a name-pinned (non-preserved) preset, so a `gemini` row pointed at a custom + // gateway answers with the same verdict the canonical `google` name returns — the alias + // inherits its owner's transport rule rather than failing open or closed on its own. + const provider = { + adapter: "google", + authMode: "key", + baseUrl: "https://operator-gateway.example/google", + } as const; + const config = configWithProviders({ gemini: provider, google: provider }); + const id = "gemini-live-2.5-flash-preview-native-audio"; + + expect(modelAcceptsImageInput(config, { provider: "gemini", id })) + .toBe(modelAcceptsImageInput(config, { provider: "google", id })); + expect(modelAcceptsImageInput(config, { provider: "gemini", id })).toBe(false); + }); + test("12. only the selected Anthropic OAuth provider contributes Anthropic options", () => { const config = configWithProviders({ anthropic: { From c1d7a58c1fedf7db11d9aa518ae957672ab0515b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:33:05 +0000 Subject: [PATCH 4/8] fix(vision): require literal transport equality for metadata-alias names Routing binds a name to a registry transport by exact id only, so an alias- or case-named row keeps its configured destination. The owning entry's generated verdict is now authoritative only when the configured adapter, auth mode, and normalized endpoint literally equal that entry's declared transport; the previous fallback applied the owner's pinning rule (google is name-pinned, so it returned true unconditionally) and let a custom gemini/anthropic-key/case-varied destination inherit verdicts for an upstream it never reaches. Co-Authored-By: Epinephrine --- src/providers/registry.ts | 45 ++++++++++++++----- src/vision/eligibility.ts | 13 +++--- structure/runtime.md | 2 +- tests/vision/vision-eligibility.test.ts | 58 ++++++++++++++++++++++--- 4 files changed, 94 insertions(+), 24 deletions(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 39db34c5fe..0a62fd2285 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -121,22 +121,47 @@ export function providerMatchesRegistryTransport( * ALIAS rather than a registry id. * * A registry row claims extra names through `extraMetadataAliases` (`gemini` for `google`, - * `anthropic-key` for `anthropic`, ...), and `resolveMetadataProvider` resolves those names — - * case-folded, the way saved provider keys arrive — to the row's metadata bundle. A provider - * saved under an alias is owned by the declaring entry, so its transport must be validated - * against that entry; an id-only lookup finds no `gemini` row and would drop a verdict the - * registry still owns. + * `anthropic-key` for `anthropic-apikey`, ...), and `resolveMetadataProvider` resolves those + * names — case-folded, the way saved provider keys arrive — to the row's metadata bundle. A + * provider saved under an alias is owned by the declaring entry, so its transport must be + * validated against that entry; an id-only lookup finds no `gemini` row and would drop a + * verdict the registry still owns. + * + * Routing binds a name to a registry transport by exact id only — `routedProviderConfig` does + * a case-sensitive `entry.id === providerName` lookup — so an alias- or case-named row keeps + * its configured destination. The owner's generated verdict is then authoritative only while + * the configured adapter, auth mode, and normalized endpoint literally equal that entry's + * declared transport; reusing the owner's pinning rule would apply vendor verdicts to + * destinations routing still serves as custom. */ export function providerMatchesRegistryTransportOrAlias( name: string, provider: Pick & Partial>, ): boolean { + const exact = getProviderRegistryEntry(name); + if (exact !== undefined) return providerMatchesRegistryTransport(name, provider); const lower = name.toLowerCase(); - const entry = getProviderRegistryEntry(name) - ?? PROVIDER_REGISTRY.find(row => - row.id.toLowerCase() === lower - || (row.extraMetadataAliases ?? []).some(alias => alias.toLowerCase() === lower)); - return entry !== undefined && providerMatchesRegistryTransport(entry.id, provider); + const owner = PROVIDER_REGISTRY.find(row => + row.id.toLowerCase() === lower + || (row.extraMetadataAliases ?? []).some(alias => alias.toLowerCase() === lower)); + return owner !== undefined && configuredTransportMatchesRegistryEntry(owner, provider); +} + +/** + * Whether a row's configured transport literally equals the declared transport of `entry`. + * Used for names routing does not pin (metadata aliases, case-varied ids): the destination + * the wire actually reaches must be the registry row's own adapter/auth/endpoint. + */ +function configuredTransportMatchesRegistryEntry( + entry: ProviderRegistryEntry, + provider: Pick & Partial>, +): boolean { + if (/\{[^}]*\}/.test(entry.baseUrl)) return false; + if (typeof provider.baseUrl !== "string") return false; + if (provider.adapter !== entry.adapter) return false; + // An unset authMode is the legacy key default, so it can only satisfy a key-auth owner. + if ((provider.authMode ?? "key") !== entry.authKind) return false; + return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); } /** diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 71e4f4816d..57509ef0a3 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -281,12 +281,13 @@ function modelAcceptsImageInputWithCache( } const fromRow = advertisesImageInput(candidate.inputModalities); if (fromRow !== undefined) return fromRow; - // A preset name is not transport identity. Some fixed key presets intentionally preserve a - // same-named custom destination, so vendor metadata is authoritative only while the configured - // adapter and endpoint still belong to the registry row that owns that name — where "owns" - // includes canonical metadata aliases like `gemini` or `anthropic-key`, resolved to the entry - // that declares them. Otherwise the capability is unknown and request dispatch must preserve - // the custom destination's image boundary. + // A preset name is not transport identity. Routing binds a name to a registry transport by + // exact id only, so vendor metadata is authoritative only while the configured adapter and + // endpoint still belong to the registry row that owns that name — where "owns" includes + // canonical metadata aliases like `gemini` or `anthropic-key`, resolved to the entry that + // declares them and then matched on the configured transport itself rather than the owner's + // pinning rule. Otherwise the capability is unknown and request dispatch must preserve the + // custom destination's image boundary. if (provider !== undefined && !providerMatchesRegistryTransportOrAlias(candidate.provider, provider)) return undefined; return metadataImageInput(candidate.provider, candidate.id); } diff --git a/structure/runtime.md b/structure/runtime.md index bd3425bf48..427abfeb04 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -452,7 +452,7 @@ declare `modelInputModalities: ["text", "image"]` per model for the nine Claude explicit operator overrides; unknown models receive no new declaration. Client eligibility filters and Anthropic image wire handling remain unchanged. -`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only while its adapter and destination still match the transport of the registry row owning its name — canonical metadata aliases such as `gemini` resolve to the entry that declares them; a same-named custom destination remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. +`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only while its adapter and destination still match the transport of the registry row owning its name — canonical metadata aliases such as `gemini` resolve to the entry that declares them and then must equal that entry's declared adapter, auth mode, and endpoint directly, because routing binds transports by exact registry id only; a same-named custom destination remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index e6bfb41d71..e6bc6a890b 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -227,10 +227,12 @@ describe("vision eligibility core", () => { expect(modelAcceptsImageInput(folded, { provider: "Gemini", id: candidate.id })).toBe(false); }); - test("11f. an alias-named custom destination follows the owning entry's pinning", () => { - // `google` is a name-pinned (non-preserved) preset, so a `gemini` row pointed at a custom - // gateway answers with the same verdict the canonical `google` name returns — the alias - // inherits its owner's transport rule rather than failing open or closed on its own. + test("11f. an alias-named custom destination is not bound by the owning entry's pinning", () => { + // `google` is a name-pinned preset: routing discards a custom baseUrl saved under that + // exact id, so the vendor verdict applies regardless. `gemini` only ALIASES the google + // bundle — routing preserves its configured destination, so at a custom gateway the + // capability is unknown and the image boundary is preserved, while the pinned `google` + // row keeps the text-only verdict. const provider = { adapter: "google", authMode: "key", @@ -239,9 +241,51 @@ describe("vision eligibility core", () => { const config = configWithProviders({ gemini: provider, google: provider }); const id = "gemini-live-2.5-flash-preview-native-audio"; - expect(modelAcceptsImageInput(config, { provider: "gemini", id })) - .toBe(modelAcceptsImageInput(config, { provider: "google", id })); - expect(modelAcceptsImageInput(config, { provider: "gemini", id })).toBe(false); + expect(modelAcceptsImageInput(config, { provider: "google", id })).toBe(false); + expect(modelAcceptsImageInput(config, { provider: "gemini", id })).toBeUndefined(); + expect(isVisionEligibleModel(config, { provider: "gemini", id })).toBe(true); + expect(requiresVisionPreprocessing(config, provider, id, "gemini")).toBe(false); + }); + + test("11g. an alias-named row on a different wire or auth mode is not the owner's transport", () => { + // A custom `gemini` fronting an OpenAI-shaped gateway keeps its own transport end to end: + // the google bundle cannot strip or redirect its images even though the name resolves. + const openaiShaped = { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://operator.example/v1", + } as const; + const config = configWithProviders({ gemini: openaiShaped }); + const id = "gemini-live-2.5-flash-preview-native-audio"; + + expect(modelAcceptsImageInput(config, { provider: "gemini", id })).toBeUndefined(); + expect(requiresVisionPreprocessing(config, openaiShaped, id, "gemini")).toBe(false); + + // Same name and endpoint as 11e but forward auth: not the key-auth google transport, so + // the verdict cannot speak for this destination either. + const forwardAuth = { + adapter: "google", + authMode: "forward", + baseUrl: "https://generativelanguage.googleapis.com", + } as const; + const forwarded = configWithProviders({ gemini: forwardAuth }); + expect(modelAcceptsImageInput(forwarded, { provider: "gemini", id })).toBeUndefined(); + expect(requiresVisionPreprocessing(forwarded, forwardAuth, id, "gemini")).toBe(false); + }); + + test("11h. a case-varied name is bound by the same transport rule as an alias", () => { + // Routing is case-sensitive, so `Gemini` is custom too: at a custom endpoint it keeps its + // image boundary (mirroring 11f), and only the canonical endpoint keeps the verdict (11e). + const custom = { + adapter: "google", + authMode: "key", + baseUrl: "https://operator-gateway.example/google", + } as const; + const config = configWithProviders({ Gemini: custom }); + const id = "gemini-live-2.5-flash-preview-native-audio"; + + expect(modelAcceptsImageInput(config, { provider: "Gemini", id })).toBeUndefined(); + expect(requiresVisionPreprocessing(config, custom, id, "Gemini")).toBe(false); }); test("12. only the selected Anthropic OAuth provider contributes Anthropic options", () => { From a44927450bd36e743b7770ce84a31e92551a0810 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:09:02 +0000 Subject: [PATCH 5/8] refactor(vision): move registry-transport matching out of capped registry.ts The alias/case-varied transport check grew src/providers/registry.ts to 280 lines, over its file-size-ratchet cap of 232. Move the matcher to a dedicated module and export normalizedProviderEndpoint; behavior unchanged. Co-Authored-By: Epinephrine --- src/providers/registry-transport.ts | 56 +++++++++++++++++++++++++++++ src/providers/registry.ts | 50 +------------------------- src/vision/eligibility.ts | 2 +- 3 files changed, 58 insertions(+), 50 deletions(-) create mode 100644 src/providers/registry-transport.ts diff --git a/src/providers/registry-transport.ts b/src/providers/registry-transport.ts new file mode 100644 index 0000000000..9d22c63a5a --- /dev/null +++ b/src/providers/registry-transport.ts @@ -0,0 +1,56 @@ +import type { OcxProviderConfig } from "../types"; +import { + PROVIDER_REGISTRY, + getProviderRegistryEntry, + normalizedProviderEndpoint, + providerMatchesRegistryTransport, +} from "./registry"; +import type { ProviderRegistryEntry } from "./registry/types"; + +/** + * `providerMatchesRegistryTransport` for a configured name that may be a generated-metadata + * ALIAS rather than a registry id. + * + * A registry row claims extra names through `extraMetadataAliases` (`gemini` for `google`, + * `anthropic-key` for `anthropic-apikey`, ...), and `resolveMetadataProvider` resolves those + * names — case-folded, the way saved provider keys arrive — to the row's metadata bundle. A + * provider saved under an alias is owned by the declaring entry, so its transport must be + * validated against that entry; an id-only lookup finds no `gemini` row and would drop a + * verdict the registry still owns. + * + * Routing binds a name to a registry transport by exact id only — `routedProviderConfig` does + * a case-sensitive `entry.id === providerName` lookup — so an alias- or case-named row keeps + * its configured destination. The owner's generated verdict is then authoritative only while + * the configured adapter, auth mode, and normalized endpoint literally equal that entry's + * declared transport; reusing the owner's pinning rule would apply vendor verdicts to + * destinations routing still serves as custom. + */ +export function providerMatchesRegistryTransportOrAlias( + name: string, + provider: Pick & Partial>, +): boolean { + const exact = getProviderRegistryEntry(name); + if (exact !== undefined) return providerMatchesRegistryTransport(name, provider); + const lower = name.toLowerCase(); + const owner = PROVIDER_REGISTRY.find(row => + row.id.toLowerCase() === lower + || (row.extraMetadataAliases ?? []).some(alias => alias.toLowerCase() === lower)); + return owner !== undefined && configuredTransportMatchesRegistryEntry(owner, provider); +} + +/** + * Whether a row's configured transport literally equals the declared transport of `entry`. + * Used for names routing does not pin (metadata aliases, case-varied ids): the destination + * the wire actually reaches must be the registry row's own adapter/auth/endpoint. + */ +function configuredTransportMatchesRegistryEntry( + entry: ProviderRegistryEntry, + provider: Pick & Partial>, +): boolean { + if (/\{[^}]*\}/.test(entry.baseUrl)) return false; + if (typeof provider.baseUrl !== "string") return false; + if (provider.adapter !== entry.adapter) return false; + // An unset authMode is the legacy key default, so it can only satisfy a key-auth owner. + if ((provider.authMode ?? "key") !== entry.authKind) return false; + return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 0a62fd2285..97ef18e58a 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -81,7 +81,7 @@ export function registryModelServiceTierCapabilityApplies( return guard === undefined || guard(provider.baseUrl); } -function normalizedProviderEndpoint(value: string): string { +export function normalizedProviderEndpoint(value: string): string { const trimmed = value.trim(); try { const parsed = new URL(trimmed); @@ -116,54 +116,6 @@ export function providerMatchesRegistryTransport( return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); } -/** - * `providerMatchesRegistryTransport` for a configured name that may be a generated-metadata - * ALIAS rather than a registry id. - * - * A registry row claims extra names through `extraMetadataAliases` (`gemini` for `google`, - * `anthropic-key` for `anthropic-apikey`, ...), and `resolveMetadataProvider` resolves those - * names — case-folded, the way saved provider keys arrive — to the row's metadata bundle. A - * provider saved under an alias is owned by the declaring entry, so its transport must be - * validated against that entry; an id-only lookup finds no `gemini` row and would drop a - * verdict the registry still owns. - * - * Routing binds a name to a registry transport by exact id only — `routedProviderConfig` does - * a case-sensitive `entry.id === providerName` lookup — so an alias- or case-named row keeps - * its configured destination. The owner's generated verdict is then authoritative only while - * the configured adapter, auth mode, and normalized endpoint literally equal that entry's - * declared transport; reusing the owner's pinning rule would apply vendor verdicts to - * destinations routing still serves as custom. - */ -export function providerMatchesRegistryTransportOrAlias( - name: string, - provider: Pick & Partial>, -): boolean { - const exact = getProviderRegistryEntry(name); - if (exact !== undefined) return providerMatchesRegistryTransport(name, provider); - const lower = name.toLowerCase(); - const owner = PROVIDER_REGISTRY.find(row => - row.id.toLowerCase() === lower - || (row.extraMetadataAliases ?? []).some(alias => alias.toLowerCase() === lower)); - return owner !== undefined && configuredTransportMatchesRegistryEntry(owner, provider); -} - -/** - * Whether a row's configured transport literally equals the declared transport of `entry`. - * Used for names routing does not pin (metadata aliases, case-varied ids): the destination - * the wire actually reaches must be the registry row's own adapter/auth/endpoint. - */ -function configuredTransportMatchesRegistryEntry( - entry: ProviderRegistryEntry, - provider: Pick & Partial>, -): boolean { - if (/\{[^}]*\}/.test(entry.baseUrl)) return false; - if (typeof provider.baseUrl !== "string") return false; - if (provider.adapter !== entry.adapter) return false; - // An unset authMode is the legacy key default, so it can only satisfy a key-auth owner. - if ((provider.authMode ?? "key") !== entry.authKind) return false; - return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); -} - /** * Resolve the registry entry a configured provider actually points at, by TRANSPORT * rather than by name. diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 57509ef0a3..b7df032bec 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -26,7 +26,7 @@ import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../gen import { nativeInputModalities } from "../codex/catalog/metadata"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import { enrichProviderFromRegistry } from "../providers/derive"; -import { providerMatchesRegistryTransportOrAlias } from "../providers/registry"; +import { providerMatchesRegistryTransportOrAlias } from "../providers/registry-transport"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; /** From 82ef87e953e196ac72ad78142894179757cca646 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:07:48 +0000 Subject: [PATCH 6/8] fix(vision): validate configured destinations against declared registry endpoints providerMatchesRegistryTransportOrAlias returned true unconditionally for exact ids on presets whose URL routing honors (allowBaseUrlOverride) and dropped verdicts for names at declared former endpoints. Both paths now share declared-destination validation: the configured adapter, auth mode, and normalized endpoint must literally equal the fixed transport, a documented baseUrlChoices endpoint, or a destinationAliases former endpoint; arbitrary custom endpoints are unknown. metadataImageInput folds the provider name case-insensitively so bundle keys (all lowercase) resolve for case-varied configured names like ZAI. Co-Authored-By: Epinephrine --- src/providers/registry-transport.ts | 50 +++++++++++++++++++------ src/vision/eligibility.ts | 14 +++++-- structure/runtime.md | 2 +- tests/vision/vision-eligibility.test.ts | 49 ++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 17 deletions(-) diff --git a/src/providers/registry-transport.ts b/src/providers/registry-transport.ts index 9d22c63a5a..4c99a9abc0 100644 --- a/src/providers/registry-transport.ts +++ b/src/providers/registry-transport.ts @@ -20,9 +20,13 @@ import type { ProviderRegistryEntry } from "./registry/types"; * * Routing binds a name to a registry transport by exact id only — `routedProviderConfig` does * a case-sensitive `entry.id === providerName` lookup — so an alias- or case-named row keeps - * its configured destination. The owner's generated verdict is then authoritative only while - * the configured adapter, auth mode, and normalized endpoint literally equal that entry's - * declared transport; reusing the owner's pinning rule would apply vendor verdicts to + * its configured destination. An `allowBaseUrlOverride` preset keeps its override, and a + * `preserveCustomDestination` preset keeps a stored row it cannot canonicalize, so for those + * names the configured URL is again the wire destination. The owner's generated verdict is + * then authoritative only while the configured adapter, auth mode, and normalized endpoint + * literally equal one of the entry's declared destinations: its fixed transport, a + * documented `baseUrlChoices` endpoint, or a `destinationAliases` former endpoint that still + * answers for the row. Reusing the owner's pinning rule would apply vendor verdicts to * destinations routing still serves as custom. */ export function providerMatchesRegistryTransportOrAlias( @@ -30,27 +34,49 @@ export function providerMatchesRegistryTransportOrAlias( provider: Pick & Partial>, ): boolean { const exact = getProviderRegistryEntry(name); - if (exact !== undefined) return providerMatchesRegistryTransport(name, provider); + if (exact !== undefined) { + // Routing discards the configured URL for a pinned name, so its mismatch under the + // pinned rule cannot move the wire. A preset whose URL routing honors + // (`allowBaseUrlOverride`) or whose stored row survives uncanonicalized + // (`preserveCustomDestination`) reaches the configured URL instead — the pinned rule's + // unconditional true no longer proves ownership there, so the destination must be one + // of the entry's declared endpoints directly. + if (exact.allowBaseUrlOverride === true || exact.preserveCustomDestination === true) { + return configuredTransportMatchesDeclaredDestinations(exact, provider); + } + return providerMatchesRegistryTransport(name, provider); + } const lower = name.toLowerCase(); const owner = PROVIDER_REGISTRY.find(row => row.id.toLowerCase() === lower || (row.extraMetadataAliases ?? []).some(alias => alias.toLowerCase() === lower)); - return owner !== undefined && configuredTransportMatchesRegistryEntry(owner, provider); + return owner !== undefined && configuredTransportMatchesDeclaredDestinations(owner, provider); } /** - * Whether a row's configured transport literally equals the declared transport of `entry`. - * Used for names routing does not pin (metadata aliases, case-varied ids): the destination - * the wire actually reaches must be the registry row's own adapter/auth/endpoint. + * Whether a row's configured transport literally equals one of `entry`'s declared + * destinations — the fixed transport, a documented `baseUrlChoices` endpoint (a "custom" + * choice declares no URL and cannot match), or a `destinationAliases` former endpoint — + * on the destination's own adapter. Used for names routing does not pin and for presets + * whose override or stored destination routing honors: the wire destination must be one + * the registry row owns for generated vendor verdicts to apply. */ -function configuredTransportMatchesRegistryEntry( +function configuredTransportMatchesDeclaredDestinations( entry: ProviderRegistryEntry, provider: Pick & Partial>, ): boolean { - if (/\{[^}]*\}/.test(entry.baseUrl)) return false; if (typeof provider.baseUrl !== "string") return false; - if (provider.adapter !== entry.adapter) return false; // An unset authMode is the legacy key default, so it can only satisfy a key-auth owner. if ((provider.authMode ?? "key") !== entry.authKind) return false; - return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); + const endpoint = normalizedProviderEndpoint(provider.baseUrl); + const declared = [ + ...(entry.destinationAliases ?? []), + ...(entry.baseUrlChoices ?? []).flatMap(choice => + choice.baseUrl === undefined ? [] : [{ adapter: entry.adapter, baseUrl: choice.baseUrl }]), + ]; + if (!/\{[^}]*\}/.test(entry.baseUrl)) { + declared.push({ adapter: entry.adapter, baseUrl: entry.baseUrl }); + } + return declared.some(target => + target.adapter === provider.adapter && normalizedProviderEndpoint(target.baseUrl) === endpoint); } diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index b7df032bec..e58c1ef86d 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -153,7 +153,10 @@ function advertisesImageInput(modalities: readonly string[] | undefined): boolea /** Vendor-table modalities for a routed row, or undefined when the table has no opinion. */ function metadataImageInput(provider: string, modelId: string): boolean | undefined { - const resolved = resolveMetadataProvider(provider) ?? provider; + // Bundle keys are lowercase, so a case-varied configured name (e.g. `ZAI`) folds the same + // way resolveMetadataProvider folds its aliases; the transport guard above decides whether + // that bundle is allowed to speak for the destination at all. + const resolved = resolveMetadataProvider(provider) ?? provider.toLowerCase(); const meta = getModelMetadataCaseInsensitive(resolved, modelId); return advertisesImageInput(meta?.input); } @@ -285,9 +288,12 @@ function modelAcceptsImageInputWithCache( // exact id only, so vendor metadata is authoritative only while the configured adapter and // endpoint still belong to the registry row that owns that name — where "owns" includes // canonical metadata aliases like `gemini` or `anthropic-key`, resolved to the entry that - // declares them and then matched on the configured transport itself rather than the owner's - // pinning rule. Otherwise the capability is unknown and request dispatch must preserve the - // custom destination's image boundary. + // declares them, and where "belong" means the configured adapter/auth/endpoint literally + // equals a declared destination (the fixed transport, a `baseUrlChoices` endpoint, or a + // `destinationAliases` former endpoint) rather than reusing the owner's pinning rule. An + // `allowBaseUrlOverride` or `preserveCustomDestination` exact id is bound the same way, + // because routing honors its configured URL. Otherwise the capability is unknown and + // request dispatch must preserve the custom destination's image boundary. if (provider !== undefined && !providerMatchesRegistryTransportOrAlias(candidate.provider, provider)) return undefined; return metadataImageInput(candidate.provider, candidate.id); } diff --git a/structure/runtime.md b/structure/runtime.md index 60e302dce2..c65e7975c4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -458,7 +458,7 @@ declare `modelInputModalities: ["text", "image"]` per model for the nine Claude explicit operator overrides; unknown models receive no new declaration. Client eligibility filters and Anthropic image wire handling remain unchanged. -`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only while its adapter and destination still match the transport of the registry row owning its name — canonical metadata aliases such as `gemini` resolve to the entry that declares them and then must equal that entry's declared adapter, auth mode, and endpoint directly, because routing binds transports by exact registry id only; a same-named custom destination remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. +`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only while its adapter and destination still match the transport of the registry row owning its name — canonical metadata aliases such as `gemini` resolve to the entry that declares them and must then equal one of the entry's declared destinations (fixed transport, documented `baseUrlChoices`, or `destinationAliases` former endpoints) on that destination's own adapter, because routing binds transports by exact registry id only; preset rows whose configured URL routing honors (`allowBaseUrlOverride`) or preserves (`preserveCustomDestination`) are validated against the same declared set, and a custom endpoint remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index e6bc6a890b..d066d51b7e 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -288,6 +288,55 @@ describe("vision eligibility core", () => { expect(requiresVisionPreprocessing(config, custom, id, "Gemini")).toBe(false); }); + test("11i. an override preset keeps vendor verdicts only at declared endpoints", () => { + // `moonshot` sets allowBaseUrlOverride + baseUrlChoices, so routing honors a configured + // URL — including the "custom" choice. The generated moonshot bundle can therefore only + // speak while the configured endpoint is a declared one (the preset default or a listed + // choice); an arbitrary gateway falls back to unknown. + const customGateway = { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://gateway.example/v1", + } as const; + const config = configWithProviders({ moonshot: customGateway }); + const candidate = { provider: "moonshot", id: "kimi-k2.5" }; + + expect(modelAcceptsImageInput(config, candidate)).toBeUndefined(); + expect(requiresVisionPreprocessing(config, customGateway, candidate.id, candidate.provider)).toBe(false); + + const declaredChina = { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://api.moonshot.cn/v1", + } as const; + const china = configWithProviders({ moonshot: declaredChina }); + expect(modelAcceptsImageInput(china, candidate)).toBe(true); + expect(requiresVisionPreprocessing(china, declaredChina, candidate.id, candidate.provider)).toBe(false); + + const canonical = configWithProviders({ moonshot: { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://api.moonshot.ai/v1", + } as const }); + expect(modelAcceptsImageInput(canonical, candidate)).toBe(true); + }); + + test("11j. a case-varied name on a declared former endpoint keeps the verdict", () => { + // `zai` declares its pre-move Chat endpoint through destinationAliases: a saved `ZAI` + // row — case-varied, so routing serves it as custom — still points at a vendor-owned + // destination, so the generated text-only verdict for glm-5.3 applies. + const provider = { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + } as const; + const config = configWithProviders({ ZAI: provider }); + const candidate = { provider: "ZAI", id: "glm-5.3" }; + + expect(modelAcceptsImageInput(config, candidate)).toBe(false); + expect(requiresVisionPreprocessing(config, provider, candidate.id, candidate.provider)).toBe(true); + }); + test("12. only the selected Anthropic OAuth provider contributes Anthropic options", () => { const config = configWithProviders({ anthropic: { From 2fc1892fa28ee6e8250491b90eba9700c7a574c4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:16:25 +0000 Subject: [PATCH 7/8] fix(vision): evaluate override presets by effective routed transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For an exact allowBaseUrlOverride id, routedProviderConfig canonicalizes the adapter to entry.adapter and derives the auth mode — only the configured URL reaches the wire — so literal adapter/auth matching dropped valid verdicts from canonical rows missing fields like authMode (a legacy google-antigravity row on the declared Google transport became unknown and skipped preprocessing). Override ids now prove ownership by endpoint membership in the entry's declared destinations, while preserveCustomDestination rows — never canonicalized — keep literal adapter/auth/endpoint matching, and the alias/case path is unchanged. Co-Authored-By: Epinephrine --- src/providers/registry-transport.ts | 86 +++++++++++++++++-------- src/vision/eligibility.ts | 6 +- structure/runtime.md | 2 +- tests/vision/vision-eligibility.test.ts | 16 +++++ 4 files changed, 79 insertions(+), 31 deletions(-) diff --git a/src/providers/registry-transport.ts b/src/providers/registry-transport.ts index 4c99a9abc0..6897a63291 100644 --- a/src/providers/registry-transport.ts +++ b/src/providers/registry-transport.ts @@ -20,14 +20,15 @@ import type { ProviderRegistryEntry } from "./registry/types"; * * Routing binds a name to a registry transport by exact id only — `routedProviderConfig` does * a case-sensitive `entry.id === providerName` lookup — so an alias- or case-named row keeps - * its configured destination. An `allowBaseUrlOverride` preset keeps its override, and a - * `preserveCustomDestination` preset keeps a stored row it cannot canonicalize, so for those - * names the configured URL is again the wire destination. The owner's generated verdict is - * then authoritative only while the configured adapter, auth mode, and normalized endpoint - * literally equal one of the entry's declared destinations: its fixed transport, a + * its configured destination, and its configured adapter, auth mode, and normalized endpoint + * must literally equal one of the entry's declared destinations: its fixed transport, a * documented `baseUrlChoices` endpoint, or a `destinationAliases` former endpoint that still - * answers for the row. Reusing the owner's pinning rule would apply vendor verdicts to - * destinations routing still serves as custom. + * answers for the row. An exact id is canonicalized instead: routing overwrites the adapter + * and derives the auth mode, and an `allowBaseUrlOverride` preset keeps only its configured + * URL, so ownership there is proven by the endpoint alone. A `preserveCustomDestination` + * preset's stored row is not canonicalized, so it again needs a literal match. Reusing the + * owner's pinning rule or an arbitrary URL would apply vendor verdicts to destinations + * routing still serves as custom. */ export function providerMatchesRegistryTransportOrAlias( name: string, @@ -36,15 +37,20 @@ export function providerMatchesRegistryTransportOrAlias( const exact = getProviderRegistryEntry(name); if (exact !== undefined) { // Routing discards the configured URL for a pinned name, so its mismatch under the - // pinned rule cannot move the wire. A preset whose URL routing honors - // (`allowBaseUrlOverride`) or whose stored row survives uncanonicalized - // (`preserveCustomDestination`) reaches the configured URL instead — the pinned rule's - // unconditional true no longer proves ownership there, so the destination must be one - // of the entry's declared endpoints directly. - if (exact.allowBaseUrlOverride === true || exact.preserveCustomDestination === true) { + // pinned rule cannot move the wire. + if (exact.allowBaseUrlOverride !== true && exact.preserveCustomDestination !== true) { + return providerMatchesRegistryTransport(name, provider); + } + // A stored row on a preserved preset is not canonicalized — the configured adapter, + // auth mode, and endpoint all reach the wire, so each must equal a declared destination. + if (exact.preserveCustomDestination === true) { return configuredTransportMatchesDeclaredDestinations(exact, provider); } - return providerMatchesRegistryTransport(name, provider); + // Routing canonicalizes the adapter to `entry.adapter` and derives the auth mode for a + // transport-matched row, preserving only the configured URL on an overridable preset; + // the destination the wire reaches is therefore the entry's own whenever the configured + // endpoint is one the entry declares. + return configuredEndpointIsDeclaredDestination(exact, provider); } const lower = name.toLowerCase(); const owner = PROVIDER_REGISTRY.find(row => @@ -53,13 +59,29 @@ export function providerMatchesRegistryTransportOrAlias( return owner !== undefined && configuredTransportMatchesDeclaredDestinations(owner, provider); } +/** + * The destinations a registry row declares as its own: its fixed transport (skipped when + * the URL is a template, which no saved row can equal), documented `baseUrlChoices` + * endpoints (a "custom" choice declares no URL and cannot match), and `destinationAliases` + * former endpoints on their own adapters. + */ +function declaredDestinations(entry: ProviderRegistryEntry): { adapter: string; baseUrl: string }[] { + const declared = [ + ...(entry.destinationAliases ?? []), + ...(entry.baseUrlChoices ?? []).flatMap(choice => + choice.baseUrl === undefined ? [] : [{ adapter: entry.adapter, baseUrl: choice.baseUrl }]), + ]; + if (!/\{[^}]*\}/.test(entry.baseUrl)) { + declared.push({ adapter: entry.adapter, baseUrl: entry.baseUrl }); + } + return declared; +} + /** * Whether a row's configured transport literally equals one of `entry`'s declared - * destinations — the fixed transport, a documented `baseUrlChoices` endpoint (a "custom" - * choice declares no URL and cannot match), or a `destinationAliases` former endpoint — - * on the destination's own adapter. Used for names routing does not pin and for presets - * whose override or stored destination routing honors: the wire destination must be one - * the registry row owns for generated vendor verdicts to apply. + * destinations on the destination's own adapter. Used for names routing does not pin and + * for preserved presets whose stored row is the wire: the destination the request actually + * reaches must be one the registry row owns for generated vendor verdicts to apply. */ function configuredTransportMatchesDeclaredDestinations( entry: ProviderRegistryEntry, @@ -69,14 +91,22 @@ function configuredTransportMatchesDeclaredDestinations( // An unset authMode is the legacy key default, so it can only satisfy a key-auth owner. if ((provider.authMode ?? "key") !== entry.authKind) return false; const endpoint = normalizedProviderEndpoint(provider.baseUrl); - const declared = [ - ...(entry.destinationAliases ?? []), - ...(entry.baseUrlChoices ?? []).flatMap(choice => - choice.baseUrl === undefined ? [] : [{ adapter: entry.adapter, baseUrl: choice.baseUrl }]), - ]; - if (!/\{[^}]*\}/.test(entry.baseUrl)) { - declared.push({ adapter: entry.adapter, baseUrl: entry.baseUrl }); - } - return declared.some(target => + return declaredDestinations(entry).some(target => target.adapter === provider.adapter && normalizedProviderEndpoint(target.baseUrl) === endpoint); } + +/** + * Whether a row's configured endpoint is one of `entry`'s declared destinations. Used for + * transport-matched exact ids on overridable presets: routing overwrites the adapter with + * `entry.adapter` and derives the auth mode, so only the URL distinguishes a canonicalized + * row from a retargeted one. + */ +function configuredEndpointIsDeclaredDestination( + entry: ProviderRegistryEntry, + provider: Pick & Partial>, +): boolean { + if (typeof provider.baseUrl !== "string") return false; + const endpoint = normalizedProviderEndpoint(provider.baseUrl); + return declaredDestinations(entry).some(target => + normalizedProviderEndpoint(target.baseUrl) === endpoint); +} diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index e58c1ef86d..8ee3e8c75b 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -291,8 +291,10 @@ function modelAcceptsImageInputWithCache( // declares them, and where "belong" means the configured adapter/auth/endpoint literally // equals a declared destination (the fixed transport, a `baseUrlChoices` endpoint, or a // `destinationAliases` former endpoint) rather than reusing the owner's pinning rule. An - // `allowBaseUrlOverride` or `preserveCustomDestination` exact id is bound the same way, - // because routing honors its configured URL. Otherwise the capability is unknown and + // `allowBaseUrlOverride` exact id is bound to the same declared set but only on the + // endpoint, because routing canonicalizes its adapter and auth and preserves just the + // configured URL; a `preserveCustomDestination` row is compared literally, because + // routing serves the stored row unchanged. Otherwise the capability is unknown and // request dispatch must preserve the custom destination's image boundary. if (provider !== undefined && !providerMatchesRegistryTransportOrAlias(candidate.provider, provider)) return undefined; return metadataImageInput(candidate.provider, candidate.id); diff --git a/structure/runtime.md b/structure/runtime.md index c65e7975c4..f3781cd9ec 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -458,7 +458,7 @@ declare `modelInputModalities: ["text", "image"]` per model for the nine Claude explicit operator overrides; unknown models receive no new declaration. Client eligibility filters and Anthropic image wire handling remain unchanged. -`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only while its adapter and destination still match the transport of the registry row owning its name — canonical metadata aliases such as `gemini` resolve to the entry that declares them and must then equal one of the entry's declared destinations (fixed transport, documented `baseUrlChoices`, or `destinationAliases` former endpoints) on that destination's own adapter, because routing binds transports by exact registry id only; preset rows whose configured URL routing honors (`allowBaseUrlOverride`) or preserves (`preserveCustomDestination`) are validated against the same declared set, and a custom endpoint remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. +`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. Registry/vendor metadata applies to a configured provider only while its adapter and destination still match the transport of the registry row owning its name — canonical metadata aliases such as `gemini` resolve to the entry that declares them and must then equal one of the entry's declared destinations (fixed transport, documented `baseUrlChoices`, or `destinationAliases` former endpoints) on that destination's own adapter, because routing binds transports by exact registry id only; preset rows are validated against the same declared set — `allowBaseUrlOverride` ids on the endpoint only (routing canonicalizes their adapter and auth), `preserveCustomDestination` rows literally (routing serves the stored row unchanged) — and a custom endpoint remains unknown. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index d066d51b7e..a4c56be4a2 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -337,6 +337,22 @@ describe("vision eligibility core", () => { expect(requiresVisionPreprocessing(config, provider, candidate.id, candidate.provider)).toBe(true); }); + test("11k. a canonical override preset missing authMode keeps the verdict", () => { + // Routing canonicalizes an exact override id to the entry's adapter and derived auth — + // only the configured URL reaches the wire — so a legacy `google-antigravity` row that + // predates authMode still lands on the declared Google transport, and the generated + // text-only verdict for gemini-live-2.5-flash-preview-native-audio applies. + const provider = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + } as const; + const config = configWithProviders({ "google-antigravity": provider }); + const candidate = { provider: "google-antigravity", id: "gemini-live-2.5-flash-preview-native-audio" }; + + expect(modelAcceptsImageInput(config, candidate)).toBe(false); + expect(requiresVisionPreprocessing(config, provider, candidate.id, candidate.provider)).toBe(true); + }); + test("12. only the selected Anthropic OAuth provider contributes Anthropic options", () => { const config = configWithProviders({ anthropic: { From 584188fce0677b45e5550357b32e23d2aa722360 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:54:30 +0000 Subject: [PATCH 8/8] ci: retrigger stalled macos 1/2 leg Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>