Skip to content
Closed
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
16 changes: 16 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1892,6 +1892,22 @@ export function providerMatchesRegistryTransport(
return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl);
}

/**
* Return the destination that routing will use for a configured provider row.
* Fixed registry transports deliberately ignore stale configured URLs, while
* templates and explicitly overridable entries use a resolved operator URL.
*/
export function effectiveProviderBaseUrl(id: string, provider: OcxProviderConfig): string {
const entry = getProviderRegistryEntry(id);
if (!entry || !providerMatchesRegistryTransport(id, provider)) return provider.baseUrl;
const configured = typeof provider.baseUrl === "string" ? provider.baseUrl.trim() : "";
const configuredIsResolved = configured.length > 0 && !/\{[^}]*\}/.test(configured);
const registryIsTemplate = /\{[^}]*\}/.test(entry.baseUrl);
return (registryIsTemplate || entry.allowBaseUrlOverride) && configuredIsResolved
? configured
: entry.baseUrl;
}

/**
* Resolve the registry entry a configured provider actually points at, by TRANSPORT
* rather than by name.
Expand Down
12 changes: 7 additions & 5 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import type { NormalizedComboConfig } from "./combos/types";
import { hasOwnProvider, resolveEnvValue } from "./config";
import { assertProviderDestinationAllowed } from "./lib/destination-policy";
import { redactSecretString, redactUrlForLog } from "./lib/redact";
import { PROVIDER_REGISTRY, providerCodexAccountMode, providerMatchesRegistryTransport } from "./providers/registry";
import {
effectiveProviderBaseUrl,
PROVIDER_REGISTRY,
providerCodexAccountMode,
providerMatchesRegistryTransport,
} from "./providers/registry";
import {
isCanonicalOpenAiForwardProvider,
LEGACY_CHATGPT_PROVIDER_ID,
Expand Down Expand Up @@ -289,16 +294,13 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
const reasoningSplitModels = mergeStringArray(registryEntry.reasoningSplitModels, provider.reasoningSplitModels);
const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels);
const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl);
const userBaseUrl = typeof provider.baseUrl === "string" ? provider.baseUrl.trim() : "";
const userBaseUrlIsResolved = userBaseUrl.length > 0 && !/\{[^}]*\}/.test(userBaseUrl);
if (registryEntry.allowBaseUrlOverride && !userBaseUrlIsResolved) {
throw new Error(`Invalid baseUrl for provider "${providerName}": expected a nonblank URL without unresolved placeholders`);
}
// Registry template URLs are presets; local/self-hosted entries opt in explicitly.
const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved
? userBaseUrl
: registryEntry.baseUrl;
const baseUrl = effectiveProviderBaseUrl(providerName, provider);
if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl);
assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork });

Expand Down
52 changes: 15 additions & 37 deletions src/routing/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

import type { OcxConfig } from "../types";
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
import { PROVIDER_REGISTRY } from "../providers/registry";
import { effectiveProviderBaseUrl, PROVIDER_REGISTRY } from "../providers/registry";
import { assessUrlDestination } from "../lib/destination-policy";
import {
nativeInputModalities,
nativeOpenAiContextWindow,
Expand Down Expand Up @@ -74,28 +75,6 @@ function cachedCatalogModels(): CatalogModelRow[] {
}
}

/**
* Classify a hostname for locality evidence. `URL.hostname` keeps IPv6
* literals bracketed (`[::1]`), so strip the brackets before matching.
* Anything not positively local or private stays unknown: "unknown is not
* zero", so an unrecognized host must never assert `remoteAllowed`.
*/
function classifyHostname(hostname: string): "local" | "private" | null {
const host = hostname.trim().toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0") return "local";
if (host === "::1" || /^127\./.test(host)) return "local";
if (/^10\./.test(host)
|| /^192\.168\./.test(host)
|| /^169\.254\./.test(host)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(host)
|| /^f[cd][0-9a-f]{2}:/.test(host)
|| /^fe80:/.test(host)
|| /^::ffff:(?:10\.|127\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(host)) {
return "private";
}
return null;
}

/**
* Adapters whose upstream protocol supports function/tool calling. Mirrors
* the adapter ids the resolver accepts (including the `azure` alias for
Expand All @@ -116,20 +95,15 @@ const TOOL_CAPABLE_ADAPTERS = new Set([

function localRemoteEvidence(baseUrl: string | undefined): Pick<RouteCapabilityEvidence, "localOnly" | "remoteAllowed"> {
if (typeof baseUrl !== "string" || baseUrl.length === 0) return {};
try {
const hostname = new URL(baseUrl).hostname;
if (!hostname) return {};
const kind = classifyHostname(hostname);
if (kind === null) return {};
// Both booleans are emitted once classified: definitive negative evidence,
// so a local host cannot satisfy `require.remoteAllowed` (or vice versa)
// under `unknownEvidence.capability: "allow"`/`"penalize"`.
return kind === "local" || kind === "private"
? { localOnly: true, remoteAllowed: false }
: { remoteAllowed: true, localOnly: false };
} catch {
return {};
const assessment = assessUrlDestination(baseUrl);
if (!assessment) return {};
if (assessment.kind === "localhost" || assessment.kind === "loopback" || assessment.kind === "private") {
return { localOnly: true, remoteAllowed: false };
}
if (assessment.kind === "public" || assessment.kind === "hostname") {
return { remoteAllowed: true, localOnly: false };
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep unresolved hostnames as unknown locality

When a self-hosted provider uses a DNS name such as ollama.internal that resolves to loopback or RFC1918 space and is admitted with allowPrivateNetwork, assessUrlDestination returns hostname; these lines therefore assert remoteAllowed: true and localOnly: false without resolving it. This makes localOnly profiles reject valid local candidates and can make remoteAllowed profiles accept private ones. Keep hostname unknown here, or use DNS-resolved evidence, rather than treating every hostname as public.

Useful? React with 👍 / 👎.

}
return {};
}

/**
Expand Down Expand Up @@ -185,7 +159,11 @@ export function candidateCapabilityEvidence(
? "supported"
: tierSupport === false ? "unsupported" : "unknown";

const localRemote = localRemoteEvidence(provider?.baseUrl);
// Evaluate policy locality against the same effective destination dispatch
// uses, not a stale configured URL that a fixed registry transport ignores.
const localRemote = localRemoteEvidence(
provider === undefined ? undefined : effectiveProviderBaseUrl(providerName, provider),
);
// Only emit a definitive encryptedCodexTasks value when the provider is
// present. An absent/unconfigured provider must stay unknown so
// require.encryptedCodexTasks does not fail closed on missing config.
Expand Down
33 changes: 33 additions & 0 deletions tests/policy-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,39 @@ describe("policy execution (RI-05)", () => {
expect(() => routeModel(config, "policy/strict")).toThrow(NoEligiblePolicyCandidateError);
});

test("local-only policy evaluates a registry provider's effective destination", () => {
const config = baseConfig({
providers: {
...baseConfig().providers,
openai: {
adapter: "openai-responses",
authMode: "forward",
baseUrl: "http://127.0.0.1:11434/v1",
allowPrivateNetwork: true,
},
},
routingProfiles: {
local: {
candidates: [{ provider: "openai", model: "gpt-5.6" }],
require: { localOnly: true },
},
},
});

try {
routeModel(config, "policy/local");
throw new Error("expected local-only policy to reject the remote registry destination");
} catch (error) {
expect(error).toBeInstanceOf(NoEligiblePolicyCandidateError);
const policyError = error as NoEligiblePolicyCandidateError;
expect(policyError.trace?.candidates[0]?.capability).toMatchObject({
localOnly: false,
remoteAllowed: true,
});
expect(policyError.trace?.candidates[0]?.exclusions[0]?.code).toBe("capability-unsatisfied");
}
});

test("unknown capability follows the profile unknownEvidence (exclude default)", () => {
// Provider "c" uses a non-tool-capable adapter and no catalog row: tools unknown.
const config = baseConfig({
Expand Down
Loading