Skip to content

feat(models): add db-backed model access overrides#218

Merged
SantiagoDePolonia merged 7 commits into
mainfrom
feat/models-override2
Apr 9, 2026
Merged

feat(models): add db-backed model access overrides#218
SantiagoDePolonia merged 7 commits into
mainfrom
feat/models-override2

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a dedicated model_overrides subsystem with DB-backed storage and atomic in-memory refresh
  • enforce model access overrides across models listing, translated requests, embeddings, batches, passthrough, and admin/dashboard surfaces
  • support MODELS_ENABLED_BY_DEFAULT, explicit enable, global force-disable, and managed API-key user_path restrictions

Merge

  • merged latest origin/main into this branch and resolved the request model resolution conflict

Verification

  • go test ./...
  • repo pre-commit hooks during both commits, including lint and hot-path performance guard

Summary by CodeRabbit

  • New Features

    • Admin UI to view/edit per-model access overrides with status badges and disabled-row styling.
    • API endpoints to list, upsert, and delete model overrides.
    • Global configuration to set default model availability (enabled_by_default).
    • Runtime enforcement: model access checks applied to listing, request validation, passthroughs, and batch submissions.
  • Tests

    • Added unit and integration tests covering override CRUD, listing, enforcement, rollback, and authorization filtering.

@coderabbitai

coderabbitai Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 85cadbe5-27a4-4310-aef8-1bcfb0628cf9

📥 Commits

Reviewing files that changed from the base of the PR and between bf80b00 and 05744a9.

📒 Files selected for processing (15)
  • .env.template
  • CLAUDE.md
  • README.md
  • docker-compose.yaml
  • docs/2026-03-23_benchmark_scripts/gateway-comparison/run-benchmark.sh
  • docs/advanced/configuration.mdx
  • helm/templates/_helpers.tpl
  • helm/templates/configmap.yaml
  • helm/templates/deployment.yaml
  • helm/templates/secret.yaml
  • helm/values.schema.json
  • helm/values.yaml
  • internal/server/http.go
  • internal/server/passthrough_provider_resolution.go
  • internal/server/request_model_resolution_test.go
💤 Files with no reviewable changes (5)
  • docs/2026-03-23_benchmark_scripts/gateway-comparison/run-benchmark.sh
  • README.md
  • docker-compose.yaml
  • helm/templates/_helpers.tpl
  • helm/values.schema.json

📝 Walkthrough

Walkthrough

Adds a model-override access-control subsystem: configuration, persistence backends, in-memory service with background refresh, admin UI/API for CRUD, request-time authorizer integrated across listing, resolution, passthrough, and batch flows, plus tests and app/server wiring.

Changes

Cohort / File(s) Summary
Configuration
config/config.example.yaml, config/config.go, .env.template, tests/integration/setup_test.go
Add top-level models config with enabled_by_default (env MODELS_ENABLED_BY_DEFAULT), update env docs/templates and test config to enable models by default.
Model-override core
internal/modeloverrides/types.go, internal/modeloverrides/service.go, internal/modeloverrides/service_test.go
Introduce Override/View/EffectiveState types, normalization/validation, Service with atomic snapshot, EffectiveState resolution, validation APIs, background refresh, and unit tests including rollback cases.
Persistence stores
internal/modeloverrides/store.go, .../store_sqlite.go, .../store_postgresql.go, .../store_mongodb.go
Define Store interface, sentinel errors and ValidationError; provide SQLite/Postgres/MongoDB store implementations with schema/index creation and CRUD semantics.
Factory & preparer
internal/modeloverrides/factory.go, internal/modeloverrides/batch_preparer.go
Factory (New/NewWithSharedStorage) producing Result lifecycle; BatchPreparer validates batch items, resolves selectors, enforces provider consistency and model access before provider submission.
App wiring & lifecycle
internal/app/app.go
Add App.modelOverrides result, initialize with shared/dedicated storage, include in shutdown ordering, and pass service into admin wiring.
Admin handler & tests
internal/admin/handler.go, internal/admin/handler_model_overrides_test.go
Add WithModelOverrides option; enrich /admin/api/v1/models responses with access info; add model-override endpoints: list/upsert/delete with error mappings and tests.
Admin UI (templates/js/css)
internal/admin/dashboard/templates/index.html, internal/admin/dashboard/static/js/modules/aliases.js, internal/admin/dashboard/static/css/dashboard.css
Add model-access badges, editor panel and form, client-side validation, async PUT/DELETE API calls, and styling for access states and disabled rows.
Server authorizer & integrations
internal/server/model_access.go, internal/server/handlers.go, internal/server/http.go, internal/server/exposed_model_lister.go
Introduce RequestModelAuthorizer interface and thread it into handlers; filter ListModels, expose ExposedModelsFiltered predicate path.
Resolution, passthrough & execution
multiple server files (e.g., request_model_resolution.go, translated_inference_service.go, native_batch_*, passthrough_*, passthrough_semantic_enrichment.go, passthrough_execution_helpers.go)
Thread authorizer into model resolution, translated plan checks, batch selection, passthrough provider resolution and access validation, and fallback filtering; add passthrough provider-name/type resolution helpers.
Provider registry/router
internal/providers/registry.go, internal/providers/router.go, internal/core/interfaces.go
Add provider-name → provider-type mapping support via GetProviderTypeForName and ProviderNameTypeResolver interface.
Aliases service
internal/aliases/service.go
Add ExposedModelsFiltered(allow func(core.ModelSelector) bool) exposing filtered projection for exposed models.
Helm & docs
helm/*, docs/*, README.md, CLAUDE.md, docker-compose.yaml, docs/.../run-benchmark.sh
Remove cache.type/CACHE_TYPE usage; shift Redis gating to presence of cache.redis.url and update Helm templates/values/schema and documentation accordingly.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Handler
    participant Resolver
    participant Authorizer
    participant Provider

    Client->>Handler: Send inference request (with requested model)
    Handler->>Resolver: Resolve requested model selector
    Resolver-->>Handler: Resolved selector
    Handler->>Authorizer: ValidateModelAccess(ctx, resolved selector)
    alt denied
        Authorizer-->>Handler: error (model_access_denied)
        Handler-->>Client: HTTP 400 (denied)
    else allowed
        Authorizer-->>Handler: ok
        Handler->>Provider: Execute request with model
        Provider-->>Handler: Response
        Handler-->>Client: HTTP 200
    end
Loading
sequenceDiagram
    participant Admin
    participant AdminUI
    participant AdminHandler
    participant OverrideService
    participant Store

    Admin->>AdminUI: Open model overrides editor
    AdminUI->>AdminHandler: GET /admin/api/v1/models
    AdminHandler->>OverrideService: List views / effective state
    OverrideService-->>AdminHandler: Models + access state
    AdminHandler-->>AdminUI: Render list

    Admin->>AdminUI: Submit override (PUT)
    AdminUI->>AdminHandler: PUT /admin/api/v1/model-overrides/{selector}
    AdminHandler->>OverrideService: Upsert override
    OverrideService->>Store: Persist override
    Store-->>OverrideService: OK
    OverrideService->>OverrideService: Refresh snapshot
    OverrideService-->>AdminHandler: Success
    AdminHandler-->>AdminUI: HTTP 200
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

release:feature

Poem

🐇 I hop through configs, build a tiny key,
I stitch the gates where models roam free.
Admins can tweak, persist, and rescind,
Paths are tidy, rules well pinned.
A joyful nibble — access under my tree!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main feature: adding database-backed model access overrides as a new subsystem with persistent storage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/models-override2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/modeloverrides/batch_preparer.go`:
- Around line 88-90: The code currently discards the second return value from
resolver.ResolveModel; update the branch that checks
p.provider.(selectorResolver) to capture the three returns (selector, changed,
err) from ResolveModel and return both selector and changed along with err so
the "changed" flag is propagated (match the pattern used in
internal/server/request_model_resolution.go); specifically modify the block
using p.provider, selectorResolver and ResolveModel to stop ignoring the second
value and pass it through.

In `@internal/modeloverrides/service.go`:
- Around line 185-205: The code currently calls s.store.Upsert / s.store.Delete
and only then s.Refresh, which can leave storage mutated while the in-memory
snapshot fails to update; instead, compute and validate the new snapshot before
committing storage and only persist if that validation succeeds. Concretely: in
Service.Upsert and Service.Delete (after normalizeSelectorInput /
selectorProviderNames), construct the prospective in-memory snapshot by applying
the normalized change to the current snapshot (or use a helper that mirrors
Refresh logic to validate the new state), ensure that snapshot build/validation
succeeds, and only then call s.store.Upsert or s.store.Delete followed by
s.Refresh; if you can’t build the snapshot beforehand, persist inside a
transactional API on s.store (begin/commit/rollback) or persist then roll back
(call s.store.Delete or restore previous entry) on Refresh failure so storage
and in-memory state remain consistent.
- Around line 328-350: The conflict resolution can leave state.Enabled true
after state.ForceDisabled was set because later rules set Enabled without
respecting ForceDisabled; update the apply function used for compiledOverride
(and any places merging modelWide/providerWide/exact) so that once
state.ForceDisabled is true it cannot be undone — either exit early from apply
when state.ForceDisabled is already set or guard the Enabled assignment
(rule.override.Enabled) to skip setting state.Enabled if state.ForceDisabled is
true; ensure state.ForceDisabled is still set when rule.override.ForceDisabled
is true and that addAllowed still runs.
- Around line 219-233: Replace the discarded call to s.Refresh(refreshCtx) with
error handling: capture the returned error (err := s.Refresh(refreshCtx)); if
non-nil, log it the same way registry.go does (e.g., s.logger.Errorf or
equivalent) and/or send it to a supervisory channel instead of dropping it; if
you choose to surface the error to callers, wrap/convert it into a
core.GatewayError instance per guidelines before propagating. Ensure you update
the anonymous goroutine around s.Refresh and keep done/ticker behavior
unchanged.

In `@internal/server/handlers.go`:
- Around line 245-252: The current branch merges unfiltered ExposedModels() when
modelAuthorizer is present but exposedModelLister does not implement
FilteredExposedModelLister; instead, when modelAuthorizer != nil you must filter
the results from exposedModelLister.ExposedModels() using
modelAuthorizer.AllowsModel before calling mergeExposedModelsResponse so denied
aliases are not reintroduced. Update the else-path to obtain the slice from
exposedModelLister.ExposedModels(), iterate and retain only models where
h.modelAuthorizer.AllowsModel(ctx, selector) returns true (using the same
selector logic as ExposedModelsFiltered), then pass the filtered slice to
mergeExposedModelsResponse; keep existing behavior unchanged when
modelAuthorizer is nil or when exposedModelLister already implements
FilteredExposedModelLister.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5383edf9-d7d2-4a8c-adcd-e188ced58649

📥 Commits

Reviewing files that changed from the base of the PR and between b73e4c5 and ccc53ba.

📒 Files selected for processing (29)
  • config/config.example.yaml
  • config/config.go
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/modules/aliases.js
  • internal/admin/dashboard/templates/index.html
  • internal/admin/handler.go
  • internal/admin/handler_model_overrides_test.go
  • internal/aliases/service.go
  • internal/app/app.go
  • internal/modeloverrides/batch_preparer.go
  • internal/modeloverrides/factory.go
  • internal/modeloverrides/service.go
  • internal/modeloverrides/service_test.go
  • internal/modeloverrides/store.go
  • internal/modeloverrides/store_mongodb.go
  • internal/modeloverrides/store_postgresql.go
  • internal/modeloverrides/store_sqlite.go
  • internal/modeloverrides/types.go
  • internal/server/execution_plan_helpers.go
  • internal/server/exposed_model_lister.go
  • internal/server/handlers.go
  • internal/server/http.go
  • internal/server/internal_chat_completion_executor.go
  • internal/server/model_access.go
  • internal/server/native_batch_service.go
  • internal/server/native_batch_support.go
  • internal/server/passthrough_service.go
  • internal/server/request_model_resolution.go
  • internal/server/translated_inference_service.go

Comment on lines +88 to +90
if resolver, ok := p.provider.(selectorResolver); ok {
selector, _, err := resolver.ResolveModel(requested)
return selector, err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect resolver implementations and existing call sites to confirm whether
# the boolean return is used as a "handled/resolved" signal anywhere else.
rg -n -C4 --type go 'func .*ResolveModel\s*\('
rg -n -C4 --type go '\.ResolveModel\s*\('

Repository: ENTERPILOT/GoModel

Length of output: 11866


Respect the resolver's second return value to match established patterns in the codebase.

ResolveModel returns (selector, changed, err). While the second value indicates whether the model was changed (not whether it was handled), the pattern in internal/server/request_model_resolution.go (lines 119, 128) shows this value should be captured and propagated, not discarded. Lines 88–90 should capture and return it instead of silently dropping it.

Suggested fix
 func (p *BatchPreparer) resolveSelector(requested core.RequestedModelSelector) (core.ModelSelector, error) {
 	if p == nil || p.provider == nil {
 		return requested.Normalize()
 	}
 	if resolver, ok := p.provider.(selectorResolver); ok {
-		selector, _, err := resolver.ResolveModel(requested)
+		selector, _, err := resolver.ResolveModel(requested)
+		if err != nil {
+			return core.ModelSelector{}, err
+		}
 		return selector, err
 	}
 	return requested.Normalize()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if resolver, ok := p.provider.(selectorResolver); ok {
selector, _, err := resolver.ResolveModel(requested)
return selector, err
if resolver, ok := p.provider.(selectorResolver); ok {
selector, _, err := resolver.ResolveModel(requested)
if err != nil {
return core.ModelSelector{}, err
}
return selector, err
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/modeloverrides/batch_preparer.go` around lines 88 - 90, The code
currently discards the second return value from resolver.ResolveModel; update
the branch that checks p.provider.(selectorResolver) to capture the three
returns (selector, changed, err) from ResolveModel and return both selector and
changed along with err so the "changed" flag is propagated (match the pattern
used in internal/server/request_model_resolution.go); specifically modify the
block using p.provider, selectorResolver and ResolveModel to stop ignoring the
second value and pass it through.

Comment thread internal/modeloverrides/service.go
Comment thread internal/modeloverrides/service.go
Comment on lines +328 to +350
apply := func(rule compiledOverride, ok bool) {
if !ok {
return
}
if rule.override.Enabled != nil && *rule.override.Enabled {
state.Enabled = true
}
if rule.override.ForceDisabled {
state.Enabled = false
state.ForceDisabled = true
}
addAllowed(rule.override.AllowedOnlyForUserPaths)
}

if modelWide, ok := snap.modelWide[model]; ok {
apply(modelWide, true)
}
if providerWide, ok := snap.providerWide[providerName]; ok {
apply(providerWide, true)
}
if exact, ok := snap.exact[exactMatchKey(providerName, model)]; ok {
apply(exact, true)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make force_disabled conflict resolution deterministic.

Lines 332-338 let a later enabled=true rule turn Enabled back on after an earlier rule has already set ForceDisabled=true. That can produce Enabled=true and ForceDisabled=true simultaneously, and AllowsModel / ValidateModelAccess will currently allow the model because they only consult Enabled.

Suggested fix
 	apply := func(rule compiledOverride, ok bool) {
 		if !ok {
 			return
 		}
-		if rule.override.Enabled != nil && *rule.override.Enabled {
-			state.Enabled = true
-		}
 		if rule.override.ForceDisabled {
 			state.Enabled = false
 			state.ForceDisabled = true
 		}
+		if !state.ForceDisabled && rule.override.Enabled != nil && *rule.override.Enabled {
+			state.Enabled = true
+		}
 		addAllowed(rule.override.AllowedOnlyForUserPaths)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
apply := func(rule compiledOverride, ok bool) {
if !ok {
return
}
if rule.override.Enabled != nil && *rule.override.Enabled {
state.Enabled = true
}
if rule.override.ForceDisabled {
state.Enabled = false
state.ForceDisabled = true
}
addAllowed(rule.override.AllowedOnlyForUserPaths)
}
if modelWide, ok := snap.modelWide[model]; ok {
apply(modelWide, true)
}
if providerWide, ok := snap.providerWide[providerName]; ok {
apply(providerWide, true)
}
if exact, ok := snap.exact[exactMatchKey(providerName, model)]; ok {
apply(exact, true)
}
apply := func(rule compiledOverride, ok bool) {
if !ok {
return
}
if rule.override.ForceDisabled {
state.Enabled = false
state.ForceDisabled = true
}
if !state.ForceDisabled && rule.override.Enabled != nil && *rule.override.Enabled {
state.Enabled = true
}
addAllowed(rule.override.AllowedOnlyForUserPaths)
}
if modelWide, ok := snap.modelWide[model]; ok {
apply(modelWide, true)
}
if providerWide, ok := snap.providerWide[providerName]; ok {
apply(providerWide, true)
}
if exact, ok := snap.exact[exactMatchKey(providerName, model)]; ok {
apply(exact, true)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/modeloverrides/service.go` around lines 328 - 350, The conflict
resolution can leave state.Enabled true after state.ForceDisabled was set
because later rules set Enabled without respecting ForceDisabled; update the
apply function used for compiledOverride (and any places merging
modelWide/providerWide/exact) so that once state.ForceDisabled is true it cannot
be undone — either exit early from apply when state.ForceDisabled is already set
or guard the Enabled assignment (rule.override.Enabled) to skip setting
state.Enabled if state.ForceDisabled is true; ensure state.ForceDisabled is
still set when rule.override.ForceDisabled is true and that addAllowed still
runs.

Comment thread internal/server/handlers.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/server/passthrough_provider_resolution.go`:
- Around line 40-76: Add a short doc comment above the function
passthroughAccessSelector explaining that it may return a core.ModelSelector
with an empty Provider field when resolvePassthroughProvider yields an empty
ProviderName and none of the candidates from
ProviderNameResolver.GetProviderName resolve to a non-empty canonical name;
state that this is intentional and that downstream authorization/validation is
expected to handle empty Provider values, and mention the roles of parameters
(provider core.RoutableProvider, info *core.PassthroughRouteInfo) and the
returned (core.ModelSelector, bool) to clarify the contract.

In `@internal/server/request_model_resolution_test.go`:
- Around line 40-52: The test double
canonicalizingProvider.GetProviderTypeForName currently returns
p.types[qualifiedModel] without trimming; change it to return
strings.TrimSpace(p.types[qualifiedModel]) so its output matches production
behavior (similar to registry.go's strings.TrimSpace(r.providerTypes[provider])
usage), ensuring you import/use strings and preserve existing early returns and
loop logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9f73c7f6-998d-49ec-908c-9116258a26a9

📥 Commits

Reviewing files that changed from the base of the PR and between ccc53ba and bf80b00.

📒 Files selected for processing (18)
  • internal/core/interfaces.go
  • internal/modeloverrides/service.go
  • internal/modeloverrides/service_test.go
  • internal/providers/registry.go
  • internal/providers/router.go
  • internal/server/handlers.go
  • internal/server/handlers_test.go
  • internal/server/http.go
  • internal/server/model_validation.go
  • internal/server/model_validation_test.go
  • internal/server/passthrough_execution_helpers.go
  • internal/server/passthrough_execution_helpers_test.go
  • internal/server/passthrough_provider_resolution.go
  • internal/server/passthrough_semantic_enrichment.go
  • internal/server/passthrough_semantic_enrichment_test.go
  • internal/server/passthrough_service.go
  • internal/server/request_model_resolution_test.go
  • tests/integration/setup_test.go

Comment thread internal/server/passthrough_provider_resolution.go
Comment thread internal/server/request_model_resolution_test.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant