Skip to content

feat(virtualmodels): unify aliases and access overrides into virtual models#416

Merged
SantiagoDePolonia merged 4 commits into
mainfrom
feat/virtual-models
Jun 22, 2026
Merged

feat(virtualmodels): unify aliases and access overrides into virtual models#416
SantiagoDePolonia merged 4 commits into
mainfrom
feat/virtual-models

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Aliases (rename a new name → a real model) and access overrides (gate a scoped selector by user_paths) were stored, served, and surfaced as two separate subsystems despite being the same operator concept. The split duplicated user_path scoping and DB-migration handling and was the root cause of recent bugs.

This unifies them behind one entity — the virtual model — in a single virtual_models table keyed by source:

  • a row with targets is a redirect (alias today; many targets + a strategy enable load balancing later),
  • a row without targets is an access policy (scoped selector gated by user_paths).

Behavior is derived from targets-presence — there is no role column — and resolution stays staged: redirect runs early (resolver), the access gate runs late (authorizer), exactly as before.

See docs/adr/0008-virtual-models.md.

Approach (low-risk composition)

To avoid re-porting subtle, tested resolution logic, the unified virtualmodels.Service composes the existing aliases and modeloverrides engines over the single store via role-partitioned adapters (redirectStorealiases.Store, policyStoremodeloverrides.Store). The two engines run verbatim; only storage, wiring, and the service face are unified.

  • One-time idempotent migration (seedFromLegacy) copies legacy aliases + model_overrides rows into virtual_models on first start when empty. Marked REMOVE-LATER.
  • Legacy tables left dormant for one release (rollback safety); a later cleanup milestone drops them and the legacy packages.
  • targets[] + strategy columns are present but inert → load balancing is a later additive change with no re-migration.
  • app.go net −14 lines: two subsystems collapsed into one.

Behavior preservation

v1 preserves today's behavior exactly:

  • single-target redirect == today's alias; policy rows == today's overrides, enforced identically.
  • MODEL_OVERRIDES_ENABLED=false still yields a nil authorizer (allow-all).
  • existing /admin/aliases and /admin/model-overrides endpoints and the current dashboard keep working unchanged (fed the composed sub-services).

Correctness guards

  • Cross-kind clobber rejected: since source is one namespace, upserting a policy whose selector equals an existing alias name (or vice-versa) is rejected with a validation error rather than silently overwriting the other row.
  • Multi-target/strategy rejected in Service.Upsert (v1) rather than silently dropped.

Tests

New internal/virtualmodels suite: store round-trip (all fields), migration seed (copy + idempotency + source collision → redirect wins), unified resolution / access-gating / list+delete routing, and the two correctness guards. All existing aliases, modeloverrides, app, gateway, server, admin suites pass unchanged.

go build ./... ✅ · go test ./... ✅ · gofmt ✅ · golangci-lint 0 issues ✅

Follow-up (not in this PR)

The unified /admin/virtual-models endpoint + single "Virtual Models" dashboard form is a frontend-focused change kept out of this PR to stay low-risk; the current UI already presents both on one page and is fully functional.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architectural decision record for the new unified “virtual model” concept and consolidated management approach.
  • New Features
    • Introduced unified storage and admin listing/management for redirects (alias-style) and access policies.
  • Refactor
    • Automatically migrates existing legacy redirect and access-override data on first startup.
  • Bug Fixes
    • Prevents redirect and policy behaviors from interfering with each other during refresh and evaluation.
  • Tests
    • Added unit and SQLite-backed coverage for migration idempotency, combined listing/deletion, and cross-type conflict handling.
  • Chores
    • Removed the persisted model overrides enable/disable configuration option and updated docs/config examples/tests accordingly.

…models

Aliases (rename -> real model) and access overrides (gate a scoped selector
by user_paths) were stored, served, and surfaced separately despite being the
same operator concept. The split duplicated user_path scoping and DB-migration
handling and caused real bugs.

Introduce a single virtual_models entity keyed by source: rows with targets are
redirects (alias now, load balancing later), rows without targets are access
policies. Behavior is derived from targets-presence; there is no role column,
and resolution stays staged (redirect early, gate late).

To avoid re-porting subtle resolution logic, the unified service composes the
existing, tested aliases and modeloverrides engines over one store via
role-partitioned adapters. A one-time, idempotent seed migrates legacy aliases
and model_overrides rows; the legacy tables are left dormant for one release.
v1 preserves today's behavior exactly; multi-target/strategy and scoped
redirects are inert fields enabled by later, additive changes.

See docs/adr/0008-virtual-models.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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: df80d270-c351-47c0-ac5a-cbd6cd8e6760

📥 Commits

Reviewing files that changed from the base of the PR and between 3b7836f and dbbcbdb.

📒 Files selected for processing (12)
  • .env.template
  • CLAUDE.md
  • config/config.example.yaml
  • config/config.go
  • config/config_test.go
  • config/models.go
  • internal/app/app.go
  • internal/app/runtime_refresh.go
  • internal/virtualmodels/factory.go
  • internal/virtualmodels/service.go
  • internal/virtualmodels/service_test.go
  • tests/integration/setup_test.go
💤 Files with no reviewable changes (3)
  • .env.template
  • tests/integration/setup_test.go
  • config/config.go

📝 Walkthrough

Walkthrough

Introduces the internal/virtualmodels package, which consolidates the existing aliases and modeloverrides subsystems into a single virtual_models store keyed by source. The package provides SQLite, PostgreSQL, and MongoDB backends, kind-partitioned adapters, a unified Service, a one-time idempotent seed migration from legacy tables, and a Result factory. The App struct is updated to replace separate alias/override fields with a single virtualModels *virtualmodels.Result. Configuration removes the OverridesEnabled flag since overrides are now always active. An ADR documents the design decisions.

Changes

Virtual Models Unification

Layer / File(s) Summary
ADR, core types, and Store interface
docs/adr/0008-virtual-models.md, internal/virtualmodels/types.go, internal/virtualmodels/store.go
ADR-0008 defines the virtual model concept. types.go introduces Target, VirtualModel, View, Catalog interface, kind constants, and alias/override conversion helpers. store.go defines ErrNotFound, the Store interface, JSON encode/decode helpers, collectVirtualModels, and stampUpsert.
SQLite, PostgreSQL, and MongoDB storage backends
internal/virtualmodels/store_sqlite.go, internal/virtualmodels/store_postgresql.go, internal/virtualmodels/store_mongodb.go, internal/virtualmodels/store_test.go, internal/virtualmodels/helpers_test.go
Implements the Store interface for all three backends with table/collection creation, CRUD operations using JSON-encoded targets/user paths, and upsert-on-conflict semantics. SQLite round-trip and not-found/delete tests verify correctness.
Kind-partitioned Store adapters
internal/virtualmodels/adapters.go
Implements redirectStore and policyStore wrappers over the shared Store. ensureSourceKind blocks cross-kind clobber on upsert; deleteOfKind prevents cross-kind deletions.
Unified Service over adapters
internal/virtualmodels/service.go, internal/virtualmodels/service_test.go
Service composes alias and override engines over the shared store using the kind adapters. Provides Refresh, ListViews (merged and sorted), Upsert (kind-routing with multi-target redirect validation), and Delete (kind-dispatch via Get). Tests cover redirect resolution, policy gating, clobber rejection, multi-target rejection, and list/delete round-trips.
Legacy seed migration
internal/virtualmodels/seed.go, internal/virtualmodels/seed_test.go
seedFromLegacy is an idempotent one-time function that copies legacy aliases and model_overrides into virtual_models when the table is empty, with redirect precedence on source collision. Tests verify copy correctness, idempotency, and collision behavior.
Factory lifecycle and app wiring
internal/virtualmodels/factory.go, internal/app/app.go, internal/app/runtime_refresh.go, internal/app/app_test.go
Result owns the Service, Store, storage connection, and background refresh stop functions with sync.Once-guarded Close. App replaces aliases/modelOverrides fields with virtualModels *virtualmodels.Result, re-derives aliasService/modelOverrideService, rewires all consumer call sites, and updates Shutdown ordering. Runtime refresh helpers pull refreshable implementations from virtualmodels.Service.
Remove OverridesEnabled config flag
.env.template, CLAUDE.md, config/config.example.yaml, config/config.go, config/models.go, config/config_test.go, tests/integration/setup_test.go
Removes the MODEL_OVERRIDES_ENABLED environment variable and OverridesEnabled config field since the override engine is now always active. Updates defaults, YAML examples, and test assertions.

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant virtualmodels as virtualmodels.New
  participant seedFromLegacy
  participant Service
  participant redirectStore
  participant policyStore

  App->>virtualmodels: New(ctx, cfg, catalog)
  virtualmodels->>seedFromLegacy: copy legacy aliases + overrides (if virtual_models empty)
  seedFromLegacy-->>virtualmodels: seeded
  virtualmodels->>Service: NewService(store, catalog)
  Service->>redirectStore: NewAliasService(redirectStore)
  Service->>policyStore: NewOverrideService(policyStore)
  virtualmodels->>Service: Refresh()
  virtualmodels-->>App: Result{Service, stopFns}

  Note over App: aliasService = virtualModels.Service.Aliases()
  Note over App: modelOverrideService = virtualModels.Service.Overrides()

  App->>Service: Upsert(vm)
  alt vm.IsRedirect()
    Service->>redirectStore: ensureSourceKind + Upsert
  else policy
    Service->>policyStore: ensureSourceKind + Upsert
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • ENTERPILOT/GoModel#147: Introduced the internal/aliases subsystem and its wiring into internal/app/app.go; this PR directly replaces that subsystem with virtualmodels.
  • ENTERPILOT/GoModel#218: Introduced the DB-backed modeloverrides subsystem and its authorizer wiring in internal/app/app.go; this PR merges that subsystem into virtualmodels.

Suggested labels

release:feature

🐇 Two tables merged into one,
Redirects and policies finally done!
A virtual_models row, keyed by source so tight,
Adapters partition by kind, left and right.
One factory to rule them, one Result to close—
The rabbit hops onward, as the unified store grows! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and directly summarizes the main change: unifying aliases and access overrides into a virtual models subsystem, which is the core objective of this comprehensive refactor.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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/virtual-models

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.

@mintlify

mintlify Bot commented Jun 22, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Jun 22, 2026, 1:15 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@codecov-commenter

codecov-commenter commented Jun 22, 2026

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

This is close, but the disabled-overrides behavior should be fixed before merging.

  • Existing deployments that disabled model overrides can start enforcing persisted policies after this change.
  • The issue is contained to the model override wiring and config removal path.

internal/app/app.go and the related model override config plumbing.

Reviews (2): Last reviewed commit: "refactor(config)!: remove MODEL_OVERRIDE..." | Re-trigger Greptile

Comment thread internal/virtualmodels/seed.go Outdated
Comment thread internal/virtualmodels/adapters.go
Comment on lines 313 to 318
func (a *App) modelOverrideService() refreshableService {
if a == nil || a.modelOverrides == nil || a.modelOverrides.Service == nil {
if a == nil || a.virtualModels == nil || a.virtualModels.Service == nil {
return nil
}
return a.modelOverrides.Service
return a.virtualModels.Service.Overrides()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Disabled overrides still refresh

This helper returns the composed override service whenever virtual models exist, even when Models.OverridesEnabled is false. Startup correctly leaves the gateway authorizer nil in that configuration, but /admin/runtime/refresh will still run and report a model_overrides refresh step as active. Operators can get an OK refresh result for a feature that is disabled and not wired into request handling.

Comment on lines +120 to +121
return &Result{
Service: service,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Disabled feature keeps polling

The override background refresh loop starts unconditionally. With MODEL_OVERRIDES_ENABLED=false, the authorizer is not wired, but this goroutine still reads and parses policy rows forever and can log refresh errors for a disabled feature. This regresses the old disabled path, where the model-overrides subsystem was not initialized at all.

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/virtualmodels/adapters.go`:
- Around line 16-31: The ensureSourceKind function performs a separate read
operation before the upsert operations occur later in the code, creating a race
condition where concurrent writes can bypass the cross-kind clobber protection.
Move the kind enforcement logic from the separate ensureSourceKind check into
the actual upsert operations (at lines 95 and 129) by implementing a conditional
update in the backend store that validates the kind atomically during the write.
This ensures that the validation and write happen together as a single atomic
operation, preventing two concurrent requests from both passing the check and
overwriting each other.
🪄 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: 3bb01699-920e-4ca6-a645-dac8808b0ade

📥 Commits

Reviewing files that changed from the base of the PR and between 0f1a218 and 79a0833.

📒 Files selected for processing (17)
  • docs/adr/0008-virtual-models.md
  • internal/app/app.go
  • internal/app/app_test.go
  • internal/app/runtime_refresh.go
  • internal/virtualmodels/adapters.go
  • internal/virtualmodels/factory.go
  • internal/virtualmodels/helpers_test.go
  • internal/virtualmodels/seed.go
  • internal/virtualmodels/seed_test.go
  • internal/virtualmodels/service.go
  • internal/virtualmodels/service_test.go
  • internal/virtualmodels/store.go
  • internal/virtualmodels/store_mongodb.go
  • internal/virtualmodels/store_postgresql.go
  • internal/virtualmodels/store_sqlite.go
  • internal/virtualmodels/store_test.go
  • internal/virtualmodels/types.go

Comment thread internal/virtualmodels/adapters.go
…sabled parity

- Migration: a source shared by a legacy alias and a legacy access override now
  fails the migration loudly instead of silently dropping the override. Dropping
  it could remove an access control and expose a previously gated model
  (provider-explicit requests bypass alias resolution).
- Cross-kind clobber guard: redirect/policy adapters now share a write mutex so
  the check-then-write upsert/delete serialize across the two engines' locks,
  closing a TOCTOU where concurrent admin writes to the same source could clobber.
- Disabled overrides: when MODEL_OVERRIDES_ENABLED=false the access-override
  engine is no longer built, refreshed, or reported by /admin/runtime/refresh,
  restoring parity with the old disabled path (authorizer stays nil = allow-all).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Thanks @greptile-apps — all four findings were valid and are addressed in 17d38e45:

P1 · Policy migration drops access (seed.go) — security. Correct, and the most important one. On a source shared by a legacy alias and a legacy access override, the migration now fails closed with an actionable error (rename the alias or the override selector) instead of silently dropping the override. Silently losing an access control — which a provider-explicit request could then bypass — was the right thing to reject.

P1 · Clobber guard races (adapters.go). Correct — the two engines hold independent locks, so the GetUpsert check-then-write wasn't atomic. The redirect/policy adapters now share a write mutex, serializing cross-kind upserts/deletes. Added a -race test run.

P2 · Disabled overrides still refresh / poll (runtime_refresh.go, factory.go). Correct parity regression. When MODEL_OVERRIDES_ENABLED=false the access-override engine is no longer built: Overrides() returns nil, so no background refresh goroutine starts, the gateway authorizer stays nil (allow-all), and /admin/runtime/refresh skips the model_overrides step — matching the old path that never initialized the subsystem.

New tests cover the fail-closed migration, the disabled-overrides path, and the cross-kind guard. go test ./... -race ✅ · golangci-lint 0 issues ✅

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

All four fixes verify correctly against the diff.

Migration fail-closed (seed.go) — collision now returns a descriptive error before inserting. The message names both the alias and override selector so the operator knows exactly what to rename. Correct.

Shared write mutex (adapters.go)redirectStore and policyStore both hold a *sync.Mutex injected at construction time from NewService. Upsert and Delete on both adapters acquire it before the ensureSourceKind/deleteOfKind check-then-write. Cross-kind serialization is solid.

Disabled overrides — no background goroutine (factory.go)stopOverride is only set when service.Overrides() != nil, so the background refresh goroutine is never started when MODEL_OVERRIDES_ENABLED=false. Clean.

Disabled overrides — runtime refresh skip (runtime_refresh.go) — The modelOverrideService() helper returns an untyped nil (not a typed (*modeloverrides.Service)(nil)) when overrides are off. That's the right call: a typed nil would satisfy the refreshableService interface and slip past the service == nil guard in runRefreshableServiceStep, causing a nil-pointer dereference on Refresh. The comment on that line makes the intent explicit — good.

The implementation is correct. LGTM.

…ca residual race

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

@coderabbitai good catch on the multi-replica angle. The shared mutex makes the check-then-write atomic within a process (the common single-instance case), but you're right that across replicas sharing one database it's best-effort.

I've chosen to document this as a known limitation rather than make it fully atomic, because:

  • This is the admin config plane (operator actions, near-zero write concurrency), not the request hot path.
  • The trigger is exotic: an alias named identically to an override selector, written concurrently to two replicas in the same window.
  • The legacy two-table design couldn't hit this, but a fully atomic fix needs a per-backend conditional upsert (SQLite json_array_length / Postgres jsonb_array_length guards + 0-rows-affected detection + a Mongo filtered update) — meaningful complexity for a vanishingly rare admin-plane race.

If multi-replica admin-write safety becomes a real requirement, the conditional-upsert approach is the fix; noted in the code comment. Happy to take it on in a follow-up if maintainers prefer.

Re: docstring coverage — the package, exported types, and exported funcs are documented; the 32% figure is counting unexported helpers and test funcs.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@SantiagoDePolonia That reasoning is solid — the admin config plane with near-zero concurrent write traffic is exactly the right context to make this call. A conditional-upsert implementation spanning SQLite/Postgres/Mongo would be non-trivial and disproportionate to the actual risk profile here, and a documented known limitation is the right disposition for now.

If multi-replica write safety does become a requirement down the line, the per-backend approach you described (targeting-column length check on insert, filtering update on Mongo) is a clean upgrade path without needing to restructure the schema. Worth capturing that sketch in a follow-up issue to avoid losing it.

On docstring coverage — that clarification makes sense; exported surface area is what matters for downstream consumers.

(´• ω •`) ♡


✏️ Learnings added
Learnt from: SantiagoDePolonia
URL: https://github.com/ENTERPILOT/GoModel/pull/416

Timestamp: 2026-06-22T13:30:58.371Z
Learning: In PR `#416` of ENTERPILOT/GoModel, the cross-kind clobber guard in `internal/virtualmodels/adapters.go` uses a shared in-process write mutex to serialize `Get`→`Upsert` check-then-write operations. This is intentionally only atomic within a single process. Cross-replica atomicity is documented as a known limitation, accepted because virtual model writes happen on the admin config plane with near-zero concurrent write concurrency. The future fix path (if multi-replica safety becomes a requirement) is a per-backend conditional upsert: SQLite `json_array_length` / Postgres `jsonb_array_length` guard + 0-rows-affected detection + Mongo filtered update.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

…lways on

After unifying aliases and access overrides, the flag had a single functional
consumer and was redundant: with the default MODELS_ENABLED_BY_DEFAULT=true and
no override rules, an always-on override engine is allow-all — identical to the
old disabled path. Removing it drops config surface and deletes the disabled
path complexity added earlier (the overridesEnabled plumbing, nil guards, and
the gated background refresh / runtime-refresh step).

BREAKING CHANGE: MODEL_OVERRIDES_ENABLED is removed (the YAML key is ignored).
The only behavior change is the self-contradictory combination
MODELS_ENABLED_BY_DEFAULT=false + MODEL_OVERRIDES_ENABLED=false, which previously
allowed all models (the disabled authorizer ignored the deny default) and now
correctly enforces the allowlist (deny unless a user-path access override
matches).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread internal/app/app.go
Comment on lines +251 to +252
aliasService := app.virtualModels.Service.Aliases()
modelOverrideService := app.virtualModels.Service.Overrides()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Disabled overrides enforced

This now always wires the access-override engine into the gateway. Deployments that set MODEL_OVERRIDES_ENABLED=false or models.overrides_enabled: false used to get a nil authorizer and allow-all behavior, but the latest change removes that config and still returns app.virtualModels.Service.Overrides(). If an environment has persisted access policies, requests can now be denied even though the operator explicitly disabled model overrides.

Context Used: CLAUDE.md (source)

@SantiagoDePolonia SantiagoDePolonia merged commit df9d185 into main Jun 22, 2026
20 checks passed
SantiagoDePolonia added a commit that referenced this pull request Jun 23, 2026
* feat(virtualmodels)!: unify engine, admin API, and dashboard UI

Finish the virtual-models unification begun in #416. Collapse the two
composed matching engines (aliases + model access overrides) into one native
engine over the single virtual_models table, behind one in-memory snapshot,
and wire a single admin endpoint and dashboard surface on top.

Backend
- Native engine: redirect (alias) resolution and policy (access) gating now run
  on VirtualModel rows directly, one atomically-swapped snapshot, one refresh
  loop. The internal/aliases and internal/modeloverrides packages are removed;
  their tested matching logic was ported. Legacy tables stay one release as a
  rollback net, read only by a self-contained seed.
- Authoritative Enabled: a policy row's Enabled governs access (disabled = off
  for everyone; enabled + user_paths = restricted; no row =
  MODELS_ENABLED_BY_DEFAULT). A single model can now be disabled.
- Scoped redirects: user_paths on a redirect are enforced at resolution via the
  optional gateway.UserPathModelResolver, so an alias applies only to matching
  callers and otherwise falls through to the literal name (the use case from the
  closed upstream PR #387).

Admin API
- One GET/PUT/DELETE /admin/virtual-models endpoint replaces /admin/aliases and
  /admin/model-overrides.

Dashboard
- One virtual-model editor (Source locked + prefilled when editing an existing
  model, an always-present target field, user_paths, a 3-state
  Enabled/Restricted/Disabled switch, description), a per-row enable/disable
  toggle on every model, and alias-like styling for any model carrying a virtual
  model. The pricing and virtual-model editors share one layout.

Cleanups
- Pre-parse redirect targets in the snapshot (no per-request ParseModelSelector
  on the hot path); validate redirect user_paths instead of silently dropping
  them; DRY the source lookup; tighten compile-time interface assertions.

See docs/adr/0008-virtual-models.md (updated).

BREAKING CHANGE: the /admin/aliases and /admin/model-overrides admin endpoints
are removed in favor of /admin/virtual-models; the internal aliases and
modeloverrides packages are deleted. Runtime model-routing behavior is
preserved; access semantics gain authoritative per-row Enabled and scoped
redirects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(virtualmodels): address review — refresh cadence, seed safety, scoped alias listing

Addresses the greptile review on #423.

- Refresh cadence (P1): the unified refresh loop used only the model-cache
  interval (default 1h), so on a multi-instance deployment a disable/restrict on
  one instance could take up to an hour to reach others — a regression from the
  policy side's prior minute cadence. Use the shorter of the model-cache and
  workflow intervals.
- Seed safety (P1): the seed wrote legacy aliases before checking access-override
  source collisions, so a conflict could abort mid-write and leave virtual_models
  partially seeded; the len(existing) > 0 guard would then skip the seed on the
  next startup and never import the access overrides. Resolve all rows and detect
  collisions before writing anything (test now asserts an empty table after a
  conflict).
- Scoped alias listing (P2): ExposedModelsFiltered only filtered by the target
  policy, so a redirect scoped to user_paths was listed in /v1/models to callers
  outside its scope even though resolution falls through for them. Add
  ExposedModelsForUserPath (hiding redirects the caller does not match) and have
  the server prefer it via the optional UserPathExposedModelLister.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(virtualmodels,dashboard): address coderabbit review

Backend
- Make request-time redirect rewrites user-path aware (Major, security): the
  shared Provider/BatchPreparer rewrite path resolved redirects with unscoped
  ResolveModel, so a user_paths-scoped alias could be applied for callers outside
  its scope in batch and guardrail-executor requests (the main chat path already
  resolved scoped via the gateway). Thread ctx through the rewrite helpers and use
  ResolveModelForUserPath for request-time rewrites; keep unscoped ResolveModel
  only for inventory/projection.
- Seed test now asserts migrated Enabled semantics, including a disabled legacy
  alias staying disabled (Enabled is authoritative).

Dashboard
- vmFormEffectiveEnabled now reflects the override's value (a disabled override
  shows "Effective now: no" even when the default is enabled), not its presence.
- toggleRowEnabled bails out when virtual models are unavailable, avoiding repeated
  failing writes after a 503.
- Create-mode overwrite confirmation runs even with an empty target, since that is
  a policy upsert that can otherwise silently replace an existing redirect/policy.
- Save is disabled while a Remove is in progress (vmDeleting), preventing concurrent
  DELETE + PUT.

Not changed: handler_usage.go pricing recalculation resolves an operator-supplied
selector that has no request user_path, and v1 redirects have a single target, so
unscoped resolution yields the correct concrete model for pricing — using the
user-path resolver there would wrongly fall through to the literal name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(virtualmodels,server): address review — exposure scoping, enable toggle, seed rollback

- /v1/models exposure scoping no longer requires a model authorizer (server,
  Major): UserPathExposedModelLister now runs even when h.modelAuthorizer is nil
  (passing a nil target-allow), so a user_paths-scoped redirect is still hidden
  from callers outside its scope instead of falling back to unscoped listing.
- Dashboard enable toggle (P1): enabling a model only DELETEs a path-less policy
  when the default actually enables it (default_enabled != false). In a
  default-disabled deployment it now PUTs enabled:true so the model is really
  enabled instead of falling back to the disabled default while the UI claims
  "enabled".
- Seed partial-write rollback (P1): if a write fails mid-seed (DB error, context
  cancellation), already-written rows are rolled back (best-effort, fresh
  context) so a partial seed cannot trip the len(existing) > 0 guard and skip
  importing the remaining access controls next startup. Test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): unify enable/restrict/disable toggle across all access scopes

Fixes the edit-form prefill for provider-wide ("{provider}/") and any policy whose
effective state was computed from override PRESENCE rather than its enabled VALUE:
providerGroupAccess.effective_enabled now honors the override's enabled flag (a
disabled provider policy reads as disabled), and the model/provider editor reads
the override's own enabled value. This also corrects the provider-group state.

Adds the same three-state switch (Enabled / Restricted / Disabled) to the provider
("{provider}/") and global ("/") rows on the models list, matching model and alias
rows. The toggle markup is extracted into one reusable {{template "access-toggle"}}
partial driven by the existing scope-agnostic rowToggle*/toggleRowEnabled helpers,
so alias rows, model rows, provider groups, and a new globalScopeRow all share it.
The redundant model-access-state-badge (and now-dead modelAccessStateText) are
removed — the toggle is the single state indicator everywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(virtualmodels): scope provider ListModels + atomic SQL seed

Addresses the latest greptile re-review.

- Provider.ListModels merged exposed redirects with the unscoped ExposedModels()
  projection, leaking user_paths-scoped redirect IDs to callers outside their
  scope (the server /v1/models path was already scoped). It has ctx, so use
  ExposedModelsForUserPath(core.UserPathFromContext(ctx), nil) for parity.
- The legacy seed now writes atomically on the SQL backends: SQLiteStore and
  PostgreSQLStore implement an optional transactional UpsertAll, and the seed
  prefers it so a failed seed leaves the table untouched rather than partially
  populated (which would trip the "already populated" guard and suppress a
  re-import next start). MongoDB — which has no cross-backend transaction without
  a replica set — keeps the per-row best-effort rollback fallback. Added an
  UpsertAll atomicity test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dashboard,virtualmodels): unify refresh cadence, rename module, collapse flags

- Refresh cadence: the unified store now refreshes solely on Cache.Model.Refresh-
  Interval (virtual models are part of the model-config plane, same cadence as the
  provider model list), dropping the Workflows.RefreshInterval min() mixing.
- Rename the dashboard controller aliases.js -> virtual-models.js (and
  dashboardAliasesModule -> dashboardVirtualModelsModule, aliases.test.cjs ->
  virtual-models.test.cjs); update the script loader, embedded-asset test, and
  template reference. The module drives the unified virtual-models UI, so the
  "aliases" name was a misnomer.
- Collapse the redundant aliasesAvailable/modelOverridesAvailable flags into the
  single virtualModelsAvailable (templates already used it).
- Remove the now-dead model-access-state-badge CSS (the toggle replaced the badge
  at every scope).

Inert load-balancing fields (Strategy, Target.Weight, multi-target) are kept per
ADR-0008.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(virtualmodels): fresh rollback ctx for Postgres seed tx + mid-batch atomicity test

Addresses coderabbit review on the UpsertAll work:
- PostgreSQLStore.UpsertAll deferred its tx.Rollback with the seed context, so a
  canceled context would skip rollback and leak cleanup to pgx. Use a fresh
  rollbackContext() (mirrors service.go). SQLite's tx.Rollback() takes no context
  and is unaffected.
- TestSQLiteStore_UpsertAllIsAtomic previously failed at BeginTx (pre-write), so
  it never proved rollback after a successful row. Replace it with a true
  mid-batch failure (row 1 written in the tx, row 2 fails to encode) and assert
  the table is left empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants