Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/providers/registry-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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, 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. 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,
provider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
): boolean {
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.
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);
}
// 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 =>
row.id.toLowerCase() === lower
|| (row.extraMetadataAliases ?? []).some(alias => alias.toLowerCase() === lower));
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 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,
provider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
): boolean {
if (typeof provider.baseUrl !== "string") 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;
const endpoint = normalizedProviderEndpoint(provider.baseUrl);
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<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
): boolean {
if (typeof provider.baseUrl !== "string") return false;
const endpoint = normalizedProviderEndpoint(provider.baseUrl);
return declaredDestinations(entry).some(target =>
normalizedProviderEndpoint(target.baseUrl) === endpoint);
}
2 changes: 1 addition & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 18 additions & 1 deletion src/vision/eligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { providerMatchesRegistryTransportOrAlias } from "../providers/registry-transport";
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination";

/**
Expand Down Expand Up @@ -152,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);
}
Expand Down Expand Up @@ -280,6 +284,19 @@ function modelAcceptsImageInputWithCache(
}
const fromRow = advertisesImageInput(candidate.inputModalities);
if (fromRow !== undefined) return fromRow;
// 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 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` 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);
}

Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
Loading
Loading