mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Add API key provider quota bypass
Expose a per-key permission toggle that stores policy:bypass-provider-quota in scopes and shows it in API Key Manager. When enabled, chat routing passes bypassQuotaPolicy through credential preflight and auto-combo candidate selection while preserving API key USD quota enforcement. Validation: focused unit tests, typecheck:core, ESLint on changed files, npm run build, Docker runner-base build, production deploy to omniroute:api-key-provider-quota-bypass-20260630191935. (cherry picked from commit 651dcc67833e865ed5718a5b89c56cdf971809e7)
This commit is contained in:
committed by
Diego Rodrigues de Sa e Souza
parent
f669335218
commit
e1c0fe4586
@@ -1278,12 +1278,22 @@ export async function handleComboChat({
|
||||
log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err });
|
||||
}
|
||||
|
||||
const autoCandidateResilienceSettings =
|
||||
relayOptions?.bypassProviderQuotaPolicy === true
|
||||
? {
|
||||
...resilienceSettings,
|
||||
quotaPreflight: {
|
||||
...resilienceSettings.quotaPreflight,
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
: resilienceSettings;
|
||||
const candidates = await buildAutoCandidates(
|
||||
eligibleTargets,
|
||||
combo.name,
|
||||
relayOptions?.sessionId,
|
||||
resetWindowConfig,
|
||||
resilienceSettings
|
||||
autoCandidateResilienceSettings
|
||||
);
|
||||
const routableCandidates = candidates.filter(
|
||||
(candidate) => candidate.quotaCutoffBlocked !== true
|
||||
|
||||
@@ -63,6 +63,7 @@ export type IsModelAvailable = (
|
||||
export type ComboRelayOptions = {
|
||||
sessionId?: string | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
bypassProviderQuotaPolicy?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManage
|
||||
import { buildApiKeyCreateScopes, mergeApiKeyPermissionScopes } from "./apiManagerScopes";
|
||||
import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/selfServiceScopes";
|
||||
import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage";
|
||||
import { hasProviderQuotaBypassScope } from "@/shared/constants/apiKeyPolicyScopes";
|
||||
import { UsageLimitSettings } from "./components/UsageLimitSettings";
|
||||
|
||||
// Constants for validation
|
||||
@@ -958,6 +959,7 @@ export default function ApiManagerPageClient() {
|
||||
: 0;
|
||||
const hasThrottle = throttleDelayMs > 0;
|
||||
const hasManageScope = Array.isArray(key.scopes) && key.scopes.includes("manage");
|
||||
const hasProviderQuotaBypass = hasProviderQuotaBypassScope(key.scopes);
|
||||
const hasJsonStreamDefault = key.streamDefaultMode === "json";
|
||||
const hasLocalUsageCommand = key.allowUsageCommand === true;
|
||||
const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0;
|
||||
@@ -985,9 +987,7 @@ export default function ApiManagerPageClient() {
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center gap-1.5">
|
||||
<code className="text-sm text-text-muted font-mono truncate">
|
||||
{visibleKeys.has(key.id)
|
||||
? (revealedKeys.get(key.id) ?? key.key)
|
||||
: key.key}
|
||||
{visibleKeys.has(key.id) ? (revealedKeys.get(key.id) ?? key.key) : key.key}
|
||||
</code>
|
||||
{allowKeyReveal ? (
|
||||
<>
|
||||
@@ -1114,6 +1114,12 @@ export default function ApiManagerPageClient() {
|
||||
USD quota
|
||||
</span>
|
||||
)}
|
||||
{hasProviderQuotaBypass && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-700 dark:text-amber-300 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">alt_route</span>
|
||||
Bypass quota policy
|
||||
</span>
|
||||
)}
|
||||
{hasSessionLimit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">group</span>
|
||||
@@ -1588,6 +1594,9 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
const [selfAccountQuotaEnabled, setSelfAccountQuotaEnabled] = useState(
|
||||
Array.isArray(apiKey?.scopes) && apiKey.scopes.includes(SELF_ACCOUNT_QUOTA_SCOPE)
|
||||
);
|
||||
const [bypassProviderQuotaPolicyEnabled, setBypassProviderQuotaPolicyEnabled] = useState(
|
||||
hasProviderQuotaBypassScope(apiKey?.scopes)
|
||||
);
|
||||
const [maxSessions, setMaxSessions] = useState(
|
||||
typeof apiKey?.maxSessions === "number" && apiKey.maxSessions > 0 ? apiKey.maxSessions : 0
|
||||
);
|
||||
@@ -1822,6 +1831,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
manageEnabled,
|
||||
selfUsageEnabled,
|
||||
selfAccountQuotaEnabled,
|
||||
bypassProviderQuotaPolicyEnabled,
|
||||
}),
|
||||
allowAllEndpoints ? [] : selectedEndpoints,
|
||||
streamDefaultMode,
|
||||
@@ -1851,6 +1861,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
manageEnabled,
|
||||
selfUsageEnabled,
|
||||
selfAccountQuotaEnabled,
|
||||
bypassProviderQuotaPolicyEnabled,
|
||||
scheduleEnabled,
|
||||
scheduleFrom,
|
||||
scheduleUntil,
|
||||
@@ -2451,6 +2462,31 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Provider Quota Policy Override */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-amber-500/20 bg-amber-500/5">
|
||||
<div className="flex flex-col gap-1 pr-2">
|
||||
<p className="text-sm font-medium text-text-main">Bypass provider quota cutoffs</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Allows this key to ignore upstream provider/account cutoff policy during routing. API
|
||||
key USD quotas still apply.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={bypassProviderQuotaPolicyEnabled}
|
||||
onClick={() => setBypassProviderQuotaPolicyEnabled((prev) => !prev)}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
bypassProviderQuotaPolicyEnabled
|
||||
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">alt_route</span>
|
||||
{bypassProviderQuotaPolicyEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Disable Non-Public Models Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
SELF_USAGE_SCOPE,
|
||||
} from "@/shared/constants/selfServiceScopes";
|
||||
import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/selfServiceScopes";
|
||||
import { API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE } from "@/shared/constants/apiKeyPolicyScopes";
|
||||
|
||||
const MANAGEMENT_SCOPE = "manage";
|
||||
|
||||
@@ -9,12 +7,14 @@ export interface CreateScopeOptions {
|
||||
manageEnabled: boolean;
|
||||
selfUsageEnabled?: boolean;
|
||||
selfAccountQuotaEnabled?: boolean;
|
||||
bypassProviderQuotaPolicyEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionScopeOptions {
|
||||
manageEnabled: boolean;
|
||||
selfUsageEnabled: boolean;
|
||||
selfAccountQuotaEnabled: boolean;
|
||||
bypassProviderQuotaPolicyEnabled: boolean;
|
||||
}
|
||||
|
||||
export function buildApiKeyCreateScopes(options: CreateScopeOptions): string[] {
|
||||
@@ -25,6 +25,9 @@ export function buildApiKeyCreateScopes(options: CreateScopeOptions): string[] {
|
||||
if (selfUsageEnabled && options.selfAccountQuotaEnabled === true) {
|
||||
scopes.push(SELF_ACCOUNT_QUOTA_SCOPE);
|
||||
}
|
||||
if (options.bypassProviderQuotaPolicyEnabled === true) {
|
||||
scopes.push(API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE);
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
@@ -41,6 +44,7 @@ export function mergeApiKeyPermissionScopes(
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
options.selfUsageEnabled && options.selfAccountQuotaEnabled
|
||||
);
|
||||
setScope(scopes, API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE, options.bypassProviderQuotaPolicyEnabled);
|
||||
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
5
src/shared/constants/apiKeyPolicyScopes.ts
Normal file
5
src/shared/constants/apiKeyPolicyScopes.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE = "policy:bypass-provider-quota";
|
||||
|
||||
export function hasProviderQuotaBypassScope(scopes: readonly string[] | null | undefined): boolean {
|
||||
return Array.isArray(scopes) && scopes.includes(API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export interface ApiKeyMetadata {
|
||||
throttleDelayMs?: number | null;
|
||||
maxSessions?: number | null;
|
||||
rateLimits?: RateLimitRule[] | null;
|
||||
scopes?: string[];
|
||||
allowedEndpoints?: string[];
|
||||
disableNonPublicModels?: boolean;
|
||||
allowUsageCommand?: boolean;
|
||||
|
||||
@@ -87,6 +87,7 @@ import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTel
|
||||
import { generateRequestId } from "../../shared/utils/requestId";
|
||||
import { logAuditEvent } from "../../lib/compliance/index";
|
||||
import { enforceApiKeyPolicy } from "../../shared/utils/apiKeyPolicy";
|
||||
import { hasProviderQuotaBypassScope } from "../../shared/constants/apiKeyPolicyScopes";
|
||||
import { cloneLogPayload } from "@/lib/logPayloads";
|
||||
import { handleInternalUsageCommand } from "@/lib/usage/internalUsageCommand";
|
||||
import {
|
||||
@@ -330,6 +331,7 @@ export async function handleChat(
|
||||
return policy.rejection;
|
||||
}
|
||||
const apiKeyInfo = policy.apiKeyInfo;
|
||||
const bypassProviderQuotaPolicy = hasProviderQuotaBypassScope(apiKeyInfo?.scopes);
|
||||
telemetry.endPhase();
|
||||
|
||||
// Guardrail pre-call pipeline — prompt injection, PII masking, and future custom rules.
|
||||
@@ -627,6 +629,7 @@ export async function handleChat(
|
||||
sessionKey: sessionAffinityKey,
|
||||
...(target?.allowRateLimitedConnection ? { allowRateLimitedConnections: true } : {}),
|
||||
...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}),
|
||||
...(bypassProviderQuotaPolicy ? { bypassQuotaPolicy: true } : {}),
|
||||
}
|
||||
);
|
||||
if (!creds || creds.allRateLimited) return false;
|
||||
@@ -642,6 +645,18 @@ export async function handleChat(
|
||||
]);
|
||||
const relayConfig =
|
||||
combo.strategy === "context-relay" ? resolveComboConfig(combo, settings) : null;
|
||||
const relayOptions =
|
||||
combo.strategy === "context-relay" || bypassProviderQuotaPolicy
|
||||
? {
|
||||
...(combo.strategy === "context-relay"
|
||||
? {
|
||||
sessionId,
|
||||
config: relayConfig,
|
||||
}
|
||||
: {}),
|
||||
...(bypassProviderQuotaPolicy ? { bypassProviderQuotaPolicy: true } : {}),
|
||||
}
|
||||
: undefined;
|
||||
telemetry.endPhase();
|
||||
|
||||
// Context-relay keeps generation in combo.ts, but handoff injection lives here
|
||||
@@ -706,13 +721,7 @@ export async function handleChat(
|
||||
settings,
|
||||
allCombos,
|
||||
apiKeyAllowedConnections: apiKeyInfo?.allowedConnections ?? null,
|
||||
relayOptions:
|
||||
combo.strategy === "context-relay"
|
||||
? {
|
||||
sessionId,
|
||||
config: relayConfig,
|
||||
}
|
||||
: undefined,
|
||||
relayOptions,
|
||||
signal: request?.signal ?? null,
|
||||
correlationId: reqId,
|
||||
});
|
||||
@@ -956,6 +965,7 @@ async function handleSingleModelChat(
|
||||
return runtimeOptions.providerId;
|
||||
})();
|
||||
const forceLiveComboTest = runtimeOptions.forceLiveComboTest === true;
|
||||
const bypassProviderQuotaPolicy = hasProviderQuotaBypassScope(apiKeyInfo?.scopes);
|
||||
const hasForcedConnection =
|
||||
typeof runtimeOptions.forcedConnectionId === "string" &&
|
||||
runtimeOptions.forcedConnectionId.trim().length > 0;
|
||||
@@ -1099,6 +1109,9 @@ async function handleSingleModelChat(
|
||||
bypassQuotaPolicy: true,
|
||||
}
|
||||
: {}),
|
||||
...(!forceLiveComboTest && bypassProviderQuotaPolicy
|
||||
? { bypassQuotaPolicy: true }
|
||||
: {}),
|
||||
...(runtimeOptions.forcedConnectionId
|
||||
? { forcedConnectionId: runtimeOptions.forcedConnectionId }
|
||||
: {}),
|
||||
|
||||
34
tests/unit/api-key-provider-quota-bypass-scope.test.ts
Normal file
34
tests/unit/api-key-provider-quota-bypass-scope.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE,
|
||||
hasProviderQuotaBypassScope,
|
||||
} from "../../src/shared/constants/apiKeyPolicyScopes.ts";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("provider quota bypass scope helper is explicit and strict", () => {
|
||||
assert.equal(API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE, "policy:bypass-provider-quota");
|
||||
assert.equal(hasProviderQuotaBypassScope([API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE]), true);
|
||||
assert.equal(hasProviderQuotaBypassScope(["policy:other"]), false);
|
||||
assert.equal(hasProviderQuotaBypassScope(null), false);
|
||||
});
|
||||
|
||||
test("chat handler maps API key provider quota bypass scope to auth bypass option", () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, "src/sse/handlers/chat.ts"), "utf8");
|
||||
|
||||
assert.match(source, /hasProviderQuotaBypassScope\(apiKeyInfo\?\.scopes\)/);
|
||||
assert.match(source, /bypassProviderQuotaPolicy[\s\S]*bypassQuotaPolicy: true/);
|
||||
assert.match(source, /relayOptions[\s\S]*bypassProviderQuotaPolicy: true/);
|
||||
});
|
||||
|
||||
test("auto combo disables hard provider quota cutoffs when relay requests bypass", () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, "open-sse/services/combo.ts"), "utf8");
|
||||
|
||||
assert.match(source, /relayOptions\?\.bypassProviderQuotaPolicy === true/);
|
||||
assert.match(source, /quotaPreflight:[\s\S]*enabled: false/);
|
||||
});
|
||||
@@ -9,10 +9,18 @@ import {
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
SELF_USAGE_SCOPE,
|
||||
} from "../../src/shared/constants/selfServiceScopes.ts";
|
||||
import { API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE } from "../../src/shared/constants/apiKeyPolicyScopes.ts";
|
||||
|
||||
test("create scopes enable own usage by default without shared account quota", () => {
|
||||
assert.deepEqual(buildApiKeyCreateScopes({ manageEnabled: false }), [SELF_USAGE_SCOPE]);
|
||||
assert.deepEqual(buildApiKeyCreateScopes({ manageEnabled: true }), ["manage", SELF_USAGE_SCOPE]);
|
||||
assert.deepEqual(
|
||||
buildApiKeyCreateScopes({
|
||||
manageEnabled: false,
|
||||
bypassProviderQuotaPolicyEnabled: true,
|
||||
}),
|
||||
[SELF_USAGE_SCOPE, API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE]
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildApiKeyCreateScopes({
|
||||
manageEnabled: false,
|
||||
@@ -28,6 +36,7 @@ test("permission scope merge preserves unrelated scopes while toggling managed s
|
||||
manageEnabled: true,
|
||||
selfUsageEnabled: true,
|
||||
selfAccountQuotaEnabled: true,
|
||||
bypassProviderQuotaPolicyEnabled: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(scopes, [
|
||||
@@ -35,6 +44,7 @@ test("permission scope merge preserves unrelated scopes while toggling managed s
|
||||
SELF_USAGE_SCOPE,
|
||||
"manage",
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -45,8 +55,29 @@ test("permission scope merge removes shared quota visibility when own usage is d
|
||||
manageEnabled: false,
|
||||
selfUsageEnabled: false,
|
||||
selfAccountQuotaEnabled: true,
|
||||
bypassProviderQuotaPolicyEnabled: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(scopes, ["custom:scope"]);
|
||||
});
|
||||
|
||||
test("permission scope merge toggles provider quota policy bypass without dropping custom scopes", () => {
|
||||
const enabled = mergeApiKeyPermissionScopes(["custom:scope"], {
|
||||
manageEnabled: false,
|
||||
selfUsageEnabled: false,
|
||||
selfAccountQuotaEnabled: false,
|
||||
bypassProviderQuotaPolicyEnabled: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(enabled, ["custom:scope", API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE]);
|
||||
|
||||
const disabled = mergeApiKeyPermissionScopes(enabled, {
|
||||
manageEnabled: false,
|
||||
selfUsageEnabled: false,
|
||||
selfAccountQuotaEnabled: false,
|
||||
bypassProviderQuotaPolicyEnabled: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(disabled, ["custom:scope"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user