Repository navigation
refactor(auth): extract model access policy from key storage #17959
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
diegosouzapw
merged 2 commits into
cheaper-inference:release/v3.8.53
from
MumuTW:codex/model-access-policy
Oct 26, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - **refactor(auth):** Extract model authorization decisions and matching into the domain layer while preserving API-key, publication, group, and cache behavior ([#17959](https://github.com/cheaper-inference/OmniRoute/pull/17959)) — thanks @MumuTW |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { hasClaudeCodeWildcardPermission, modelPatternMatches } from "./modelPermissions"; | ||
|
|
||
| export interface ModelAccessPolicy { | ||
| id?: string; | ||
| modelAccessMode: "all" | "restricted"; | ||
| allowedModels?: string[]; | ||
| blockedModels?: string[]; | ||
| disableNonPublicModels?: boolean; | ||
| } | ||
|
|
||
| export interface ModelAccessTarget { | ||
| modelId: string; | ||
| resolvedModelId?: string | null; | ||
| } | ||
|
|
||
| /** Read ports are lazy: a deny must not trigger later catalog/group lookups. */ | ||
| export interface ModelAccessLookups { | ||
| getCandidates(modelId: string): Promise<string[]>; | ||
| resolveAlias(modelId: string): string; | ||
| getPublishedTarget(modelId: string): Promise<{ providerId: string; modelId: string } | null>; | ||
| findPublishedModel( | ||
| providerOrAlias: string, | ||
| modelId: string | ||
| ): Promise<{ providerId: string; publishedModelId: string } | null>; | ||
| isPublishedModelHidden(providerId: string, alias: string, modelId: string): boolean; | ||
| checkGroupAccess(keyId: string, modelId: string, provider?: string): { allowed: boolean }; | ||
| isDeniedUnderCanonicalProvider( | ||
| keyId: string, | ||
| provider: string | undefined, | ||
| modelId: string | ||
| ): Promise<boolean>; | ||
| } | ||
|
|
||
| export type ModelAccessDecision = | ||
| | { allowed: false; reason: "blocked" | "unpublished" | "hidden" } | ||
| | { allowed: boolean; reason: "permissions" }; | ||
|
|
||
| /** | ||
| * Decide model access independently of key storage and permission caching. | ||
| * Eligibility denials precede the final per-key/group permission decision. | ||
| */ | ||
| export async function evaluateModelAccess( | ||
| policy: ModelAccessPolicy, | ||
| { modelId, resolvedModelId }: ModelAccessTarget, | ||
| lookups: ModelAccessLookups | ||
| ): Promise<ModelAccessDecision> { | ||
| const { allowedModels, blockedModels, disableNonPublicModels, modelAccessMode } = policy; | ||
| // Allow rules retain the requested alias; deny rules also see the resolved target. | ||
| const candidates = Array.from( | ||
| new Set([ | ||
| ...(await lookups.getCandidates(modelId)), | ||
| ...(resolvedModelId ? await lookups.getCandidates(resolvedModelId) : []), | ||
| ]) | ||
| ); | ||
|
|
||
| if (blockedModels?.some((pattern) => modelPatternMatches(pattern, candidates))) { | ||
| return { allowed: false, reason: "blocked" }; | ||
| } | ||
|
|
||
| if (disableNonPublicModels) { | ||
| const effectiveModelId = resolvedModelId || lookups.resolveAlias(modelId) || modelId; | ||
| const publicationCandidates = resolvedModelId | ||
| ? await lookups.getCandidates(resolvedModelId) | ||
| : candidates; | ||
|
|
||
| if (!hasClaudeCodeWildcardPermission(allowedModels, publicationCandidates)) { | ||
| const lookupTarget = await lookups.getPublishedTarget(effectiveModelId); | ||
| const providerOrAlias = lookupTarget?.providerId || effectiveModelId.split("/")[0]; | ||
| const shortModelId = lookupTarget?.modelId || effectiveModelId.split("/").slice(1).join("/"); | ||
| if (!providerOrAlias || !shortModelId) return { allowed: false, reason: "unpublished" }; | ||
|
|
||
| const published = await lookups.findPublishedModel(providerOrAlias, shortModelId); | ||
| if (!published) return { allowed: false, reason: "unpublished" }; | ||
| const { providerId, publishedModelId } = published; | ||
|
|
||
| // Effort variants dispatch to a base model whose deny rules still apply. | ||
| if (publishedModelId !== shortModelId && blockedModels?.length) { | ||
| const baseCandidates = await lookups.getCandidates(`${providerId}/${publishedModelId}`); | ||
| if (blockedModels.some((pattern) => modelPatternMatches(pattern, baseCandidates))) { | ||
| return { allowed: false, reason: "blocked" }; | ||
| } | ||
| } | ||
|
|
||
| if (lookups.isPublishedModelHidden(providerId, providerOrAlias, publishedModelId)) { | ||
| return { allowed: false, reason: "hidden" }; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Restricted + [] is deny-all. Groups can narrow, never expand, this grant. | ||
| let allowed = | ||
| !allowedModels || allowedModels.length === 0 | ||
| ? modelAccessMode !== "restricted" | ||
| : allowedModels.some((pattern) => modelPatternMatches(pattern, candidates)); | ||
|
|
||
| if (policy.id) { | ||
| for (const groupModelId of new Set([modelId, ...(resolvedModelId ? [resolvedModelId] : [])])) { | ||
| const hasProvider = groupModelId.includes("/"); | ||
| const provider = hasProvider ? groupModelId.split("/")[0] : undefined; | ||
| const target = hasProvider ? groupModelId.split("/").slice(1).join("/") : groupModelId; | ||
| const targetOk = lookups.checkGroupAccess(policy.id, target, provider).allowed; | ||
| const fullOk = lookups.checkGroupAccess(policy.id, groupModelId, provider).allowed; | ||
| if (!targetOk || !fullOk) allowed = false; | ||
|
|
||
| if (allowed && (await lookups.isDeniedUnderCanonicalProvider(policy.id, provider, target))) { | ||
| allowed = false; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return { allowed, reason: "permissions" }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| // Pure model-permission matching. Runtime settings and storage are resolved by callers. | ||
|
|
||
| import { getProviderByAlias, resolveProviderId } from "@/shared/constants/providers"; | ||
|
|
||
| export const CLAUDE_CODE_PROVIDER_PREFIXES = new Set(["cc", "claude"]); | ||
|
|
||
| export const CLAUDE_CODE_SHORT_ALIASES = new Set(["sonnet", "opus", "haiku", "fable"]); | ||
|
|
||
| export function stripExtendedContextSuffix(modelId: string): string { | ||
| return modelId.endsWith("[1m]") ? modelId.slice(0, -4) : modelId; | ||
| } | ||
|
|
||
| export function isPotentialUnprefixedClaudeCodeModel(modelId: string): boolean { | ||
| const clean = stripExtendedContextSuffix(modelId.trim()); | ||
| return /^claude-/i.test(clean) || CLAUDE_CODE_SHORT_ALIASES.has(clean.toLowerCase()); | ||
| } | ||
|
|
||
| export function addModelCandidate(candidates: Set<string>, modelId: string): void { | ||
| const clean = modelId.trim(); | ||
| if (!clean) return; | ||
| candidates.add(clean); | ||
| candidates.add(stripExtendedContextSuffix(clean)); | ||
| } | ||
|
|
||
| /** | ||
| * Expand provider-scoped model ids with canonical provider id + public alias forms | ||
| * (e.g. codex/gpt-5.6-terra ↔ cx/gpt-5.6-terra) so API-key allow/block patterns match | ||
| * dashboard restrictions regardless of which prefix the client sends. | ||
| */ | ||
| export function addProviderAliasScopedCandidates( | ||
| candidates: Set<string>, | ||
| providerOrAlias: string, | ||
| providerScopedModel: string, | ||
| resolveProviderId: (aliasOrId: string) => string, | ||
| getProviderAlias: (providerId: string) => string | ||
| ): void { | ||
| if (!providerScopedModel) return; | ||
| const canonicalId = resolveProviderId(providerOrAlias); | ||
| const alias = getProviderAlias(canonicalId); | ||
| if (canonicalId !== providerOrAlias) { | ||
| addModelCandidate(candidates, `${canonicalId}/${providerScopedModel}`); | ||
| } | ||
| if (alias !== providerOrAlias && alias !== canonicalId) { | ||
| addModelCandidate(candidates, `${alias}/${providerScopedModel}`); | ||
| } | ||
| } | ||
|
|
||
| export function modelPatternMatches(pattern: string, candidates: string[]): boolean { | ||
| for (const candidate of candidates) { | ||
| if (pattern === candidate) return true; | ||
| if (pattern.endsWith("/*")) { | ||
| const prefix = pattern.slice(0, -2); | ||
| if (!candidate.startsWith(prefix + "/")) continue; | ||
| // `provider/*` covers that provider's own models, including ids whose | ||
| // model segment itself contains a slash (`cline/deepseek/deepseek-v4-flash`). | ||
| // It must not also cover a different provider nested under the same prefix | ||
| // (`cline/openrouter/deepseek/deepseek-v4-flash`): that model is served by | ||
| // openrouter, which this entry never named. | ||
| const rest = candidate.slice(prefix.length + 1); | ||
| const nested = rest.indexOf("/"); | ||
| if (nested > 0 && rest.slice(nested + 1).includes("/")) { | ||
| const nextSegment = rest.slice(0, nested); | ||
| const nestedProvider = getProviderByAlias(nextSegment); | ||
| // `<gateway>/<other-provider>/<model>` is another provider's model | ||
| // reached through this gateway (`cline/openrouter/deepseek/deepseek-v4-flash`). | ||
| // `<gateway>/<family>/<model>` is still this gateway's own model even | ||
| // when the family name is also a provider (`cline/deepseek/deepseek-v4-flash`). | ||
| const namedProvider = resolveProviderId(prefix.split("/")[0]); | ||
| if (nestedProvider && nestedProvider.id !== namedProvider) continue; | ||
| } | ||
| return true; | ||
| } | ||
| if (pattern.includes("*") && matchesWildcardPattern(pattern, candidate)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| export function hasClaudeCodeWildcardPermission( | ||
| allowedModels: string[] | undefined, | ||
| candidates: string[] | ||
| ): boolean { | ||
| if (!allowedModels || allowedModels.length === 0) return false; | ||
| return allowedModels.some( | ||
| (pattern) => | ||
| (pattern === "cc/*" || pattern === "claude/*") && | ||
| candidates.some((candidate) => modelPatternMatches(pattern, [candidate])) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Match an API-key wildcard scope pattern against a model id without | ||
| * compiling a RegExp from string concatenation (avoid ReDoS exposure on | ||
| * operator-supplied patterns and silence the Semgrep `js/regex-injection` | ||
| * advisory for `new RegExp(<dynamic>)`). | ||
| * | ||
| * Supported pattern syntax (only what real scopes use): | ||
| * - literal segments | ||
| * - `*` matches any run of characters, but does NOT cross `/` | ||
| * | ||
| * Walks the pattern token-by-token: each `*` consumes the longest possible | ||
| * run within the current path segment, then the next literal anchor must | ||
| * appear before the segment boundary. Worst-case complexity is O(n*m) | ||
| * where n = pattern length, m = candidate length — there is no nested | ||
| * backtracking that could explode adversarially. | ||
| */ | ||
| export function matchesWildcardPattern(pattern: string, candidate: string): boolean { | ||
| const pSegs = pattern.split("/"); | ||
| const cSegs = candidate.split("/"); | ||
| if (pSegs.length !== cSegs.length) return false; | ||
| for (let i = 0; i < pSegs.length; i++) { | ||
| if (!segmentMatchesWildcard(pSegs[i], cSegs[i])) return false; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| export function segmentMatchesWildcard(pattern: string, segment: string): boolean { | ||
| if (pattern === segment) return true; | ||
| if (!pattern.includes("*")) return false; | ||
| const parts = pattern.split("*"); | ||
| // Anchor first literal to the start. | ||
| let cursor = 0; | ||
| const first = parts[0]; | ||
| if (first) { | ||
| if (!segment.startsWith(first)) return false; | ||
| cursor = first.length; | ||
| } | ||
| // Anchor last literal to the end. | ||
| const last = parts[parts.length - 1]; | ||
| const endLimit = segment.length - last.length; | ||
| if (last) { | ||
| if (!segment.endsWith(last)) return false; | ||
| } | ||
| // Each middle literal must appear in order between cursor and endLimit. | ||
| for (let i = 1; i < parts.length - 1; i++) { | ||
| const piece = parts[i]; | ||
| if (!piece) continue; | ||
| const idx = segment.indexOf(piece, cursor); | ||
| if (idx === -1 || idx + piece.length > endLimit) return false; | ||
| cursor = idx + piece.length; | ||
| } | ||
| return cursor <= endLimit; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.