mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
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:
committed by
GitHub
parent
c68cda7dfb
commit
65e81158ab
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user