fix(ollama): route models by advertised capability (#11088)

Landed with the design call resolved per the owner's pick — **option 1**: the synced store is now endpoint-agnostic (persistDiscoveredModels and managedModelImport no longer drop non-chat models at write time), and chat selectability moved to read time (auto-pool expansion in autoStrategy applies filterChatSelectableModels; the models-route projection already had its chatOnly filter). Your discovery test now passes end-to-end (3/3): /api/show capabilities persist per connection and image/embedding requests route through the advertising host.

Reconciliation notes: conflicted areas merged onto the current tip (adobe discovery import, requestedModel preflight signature, resolvedProvider fast-path coexists with the synced-route override — explicit resolution wins); carried base-red drains (#10055 memoization, #11071 test variants) dropped as already-landed; the managed-model-import exclusion test was propagated to the new contract (image/video models persist; the read filter still hides them from chat pickers — pinned by a new assertion). Full battery: 205/206 focused (the one red is a confirmed periodic-timer timing flake on the loaded devbox — 20/20 isolated), autoCombo vitest 30/30, combo suites 46/46, gates + typecheck clean.

Thank you @yourspraveen — the capability probe + routing design was right; it just needed the store contract opened up. Fixes #11087.
This commit is contained in:
Praveen K Palaniswamy
2026-08-23 10:45:01 -04:00
committed by GitHub
parent c68cda7dfb
commit 65e81158ab
5094 changed files with 564668 additions and 80301 deletions

View File

@@ -31,6 +31,7 @@ import { resolveEndpointCategory } from "@/shared/constants/endpointCategories";
import { resolveQuotaKeyScope } from "@/lib/quota/quotaKey";
import { isQuotaModelName, parseQuotaModelName } from "@/lib/quota/quotaModelNaming";
import { buildApiKeyUsageLimitPolicyRejection } from "@/lib/usage/apiKeyUsageLimits";
import { ALL_COMBOS_ACCESS_RULE } from "@/shared/constants/comboAccess";
// Default to no per-key request cap. API keys can still opt into explicit
// limits via Settings/API Keys, while provider/account quota controls remain
@@ -73,6 +74,7 @@ interface AccessSchedule {
export interface ApiKeyMetadata {
id: string;
name?: string;
modelAccessMode?: "all" | "restricted";
allowedModels?: string[];
allowedCombos?: string[];
allowedConnections?: string[];
@@ -97,6 +99,7 @@ export interface ApiKeyMetadata {
usageLimitEnabled?: boolean;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
compressionEnabled?: boolean;
}
/**
@@ -179,6 +182,7 @@ function normalizeComboAccessName(value: unknown): string | null {
}
function matchesComboAccessRule(comboName: string, requestedModel: string, rule: string): boolean {
if (rule === ALL_COMBOS_ACCESS_RULE) return true;
const normalizedRule = normalizeComboAccessName(rule);
if (!normalizedRule) return false;
return (
@@ -301,7 +305,7 @@ async function validateStandardRoutingTarget(
modelStr: string
): Promise<Response | null> {
let requestedComboName: string | null = null;
if (apiKeyInfo.allowedCombos && apiKeyInfo.allowedCombos.length > 0) {
if (Array.isArray(apiKeyInfo.allowedCombos)) {
try {
const comboAccess = await isComboAllowedForKey(apiKeyInfo.allowedCombos, modelStr);
requestedComboName = comboAccess.comboName;
@@ -318,7 +322,8 @@ async function validateStandardRoutingTarget(
}
const hasModelRestrictions =
(apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) ||
apiKeyInfo.modelAccessMode === "restricted" ||
Boolean(apiKeyInfo.allowedModels?.length) ||
apiKeyInfo.disableNonPublicModels === true;
if (!requestedComboName && hasModelRestrictions && modelStr.startsWith("auto/")) {
requestedComboName = modelStr;
@@ -524,7 +529,9 @@ async function validateModelAccess(context: PolicyContext): Promise<Response | n
let requestedComboName = comboAccess.comboName;
const hasModelRestrictions =
Boolean(apiKeyInfo.allowedModels?.length) || apiKeyInfo.disableNonPublicModels === true;
apiKeyInfo.modelAccessMode === "restricted" ||
Boolean(apiKeyInfo.allowedModels?.length) ||
apiKeyInfo.disableNonPublicModels === true;
if (!requestedComboName && hasModelRestrictions) {
if (modelStr.startsWith("auto/") || modelStr.startsWith("qtSd/")) {
requestedComboName = modelStr;
@@ -552,7 +559,7 @@ async function validateComboAccess(
allowedCombos: string[] | undefined,
modelStr: string
): Promise<{ comboName: string | null; rejection: Response | null }> {
if (!allowedCombos?.length) return { comboName: null, rejection: null };
if (!Array.isArray(allowedCombos)) return { comboName: null, rejection: null };
try {
const comboAccess = await isComboAllowedForKey(allowedCombos, modelStr);
if (comboAccess.allowed) return { comboName: comboAccess.comboName, rejection: null };
@@ -638,13 +645,37 @@ async function validateRateLimitAndThrottle(context: PolicyContext): Promise<Res
return null;
}
/**
* A bare `x-api-key` / `x-goog-api-key` (no anthropic-version, no claude
* user-agent) is accepted by the CLIENT_API auth layer (clientApi.ts
* `extractBearer`) but ignored by the Issue-#2225-gated `extractApiKey()` used
* for policy resolution — so a genuine key sent that way passed auth while
* skipping its own allowedModels / budget / rate-limit policy
* (GHSA-2phc-xp22-9f56). Resolve those headers here so the policy layer sees the
* same key auth accepted. Bearer, URL-token and anthropic-gated paths are already
* covered by `extractApiKey()`; unknown keys still fail open downstream, so this
* only tightens enforcement for real keys.
*/
function extractUngatedClientApiKey(request: Request): string | null {
const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key");
if (xApiKey && xApiKey.trim()) return xApiKey.trim();
const xGoog = request.headers.get("x-goog-api-key") ?? request.headers.get("X-Goog-Api-Key");
if (xGoog && xGoog.trim()) return xGoog.trim();
return null;
}
export async function enforceApiKeyPolicy(
request: Request,
modelStr: string | null
): Promise<ApiKeyPolicyResult> {
// A real bearer key wins; otherwise an authenticated dashboard playground may
// test a specific key's policy by id (resolved server-side, secret never sent).
const apiKey = extractApiKey(request) || (await resolvePlaygroundTestKey(request));
// A real bearer key wins; then a bare x-api-key/x-goog-api-key that auth
// accepted but extractApiKey() gates out; otherwise an authenticated dashboard
// playground may test a specific key's policy by id (resolved server-side,
// secret never sent).
const apiKey =
extractApiKey(request) ||
extractUngatedClientApiKey(request) ||
(await resolvePlaygroundTestKey(request));
// No API key = local/session mode, skip policy checks
if (!apiKey) {