Skip to content

feat(virtualmodels)!: unify engine, admin API, and dashboard UI#423

Merged
SantiagoDePolonia merged 9 commits into
mainfrom
feat/unifying-ui-for-virtual-models
Jun 23, 2026
Merged

feat(virtualmodels)!: unify engine, admin API, and dashboard UI#423
SantiagoDePolonia merged 9 commits into
mainfrom
feat/unifying-ui-for-virtual-models

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Finishes the virtual-models unification started in #416. Aliases and model access overrides are now one virtual model entity end to end: one native engine, one admin endpoint, one dashboard editor.

Backend

  • One native engine, one in-memory index. Redirect (alias) resolution and policy (access) gating run natively on VirtualModel rows behind a single atomically-swapped snapshot, one refresh loop. internal/aliases and internal/modeloverrides are deleted (logic ported; legacy tables kept one release as a rollback net, read only by a self-contained seed).
  • Authoritative Enabled. A policy'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 (covers PR feat(aliases): add user_path restrictions to model aliases #387). user_paths on a redirect are enforced at resolution via the optional gateway.UserPathModelResolver; a scoped alias applies only to matching callers and otherwise falls through to the literal model name.

Admin API (breaking)

  • GET/PUT/DELETE /admin/virtual-models 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.
  • Per-row enable/disable toggle on every model; alias-like badge/row styling for any model that carries a virtual model; the pricing and virtual-model editors share one layout.

Cleanups / perf

  • Pre-parse redirect targets in the snapshot (no per-request ParseModelSelector on the hot path).
  • Validate redirect user_paths instead of silently dropping them (a dropped path would widen a scoped alias to everyone).
  • DRY the canonical-source lookup; tighten compile-time interface assertions.

Verification

  • go build ./..., go vet ./..., go test ./... — 57 packages green.
  • node --test …/*.test.cjs — 318 JS tests green.
  • Manual E2E against a running instance: alias create + /v1/models exposure, disable/enable a real model, and the PR feat(aliases): add user_path restrictions to model aliases #387 scoped redirect (matching user_path resolves to the target; others fall through to model_not_found).

ADR docs/adr/0008-virtual-models.md updated.

Notes / follow-ups

  • docs/openapi.json still lists /admin/model-overrides: make docs-openapi fails on a pre-existing swag parse error in internal/server/messages_handler.go (json.RawMessage), unrelated to this change, so the generated spec wasn't regenerated.
  • /v1/models exposure for a scoped redirect is unscoped (listed for everyone); resolution/enforcement is correct. Filtering the listing by per-redirect user_paths is a small follow-up.
  • Unifying the two batch-rewrite paths into one (with an access-validator hook) is a deferred DRY cleanup.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Unified virtual models management consolidates redirect and access policy administration into a single editor interface.
    • Admin API now uses /admin/virtual-models endpoint for managing model redirects and policies.
  • Documentation

    • Architecture decision record updated with virtual models unification approach.

SantiagoDePolonia and others added 2 commits June 23, 2026 10:47
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>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Removes the legacy internal/aliases and internal/modeloverrides packages and replaces them with a single native internal/virtualmodels engine. Rewires the app, admin HTTP handlers, gateway resolver, and dashboard to a unified /admin/virtual-models endpoint. Adds user-path-scoped redirect resolution, legacy data migration via seed_legacy.go, and a consolidated dashboard editor modal.

Changes

Virtual models unification across backend and dashboard

Layer / File(s) Summary
Unified contracts, validation, and snapshot indexes
internal/virtualmodels/types.go, internal/virtualmodels/validation.go, internal/virtualmodels/snapshot.go
VirtualModel gains clone()/targetSelector(); new Resolution/EffectiveState types; normalizeRedirect/normalizePolicyInput/normalizeUserPaths validation; immutable snapshot with exact/provider-wide/global policy precedence.
Native virtualmodels service lifecycle and persistence
internal/virtualmodels/service.go
Replaces composed alias+override wiring with one Service owning an atomic.Value snapshot; centralized Refresh/StartBackgroundRefresh; Upsert/Delete with pre-write snapshot validation and rollback on refresh failure.
Resolution and authorization on unified service
internal/virtualmodels/resolve.go, internal/virtualmodels/authorize.go
ResolveModel/ResolveModelForUserPath/ResolveRefreshTarget; AllowsModel/ValidateModelAccess/FilterPublicModels; ExposedModelsForUserPath with user-path ancestor matching via binary search.
Provider and batch rewrite moved to virtualmodels
internal/virtualmodels/provider.go, internal/virtualmodels/batch_preparer.go
New BatchPreparer combining redirect rewrite and access validation; NewProviderWithOptions added; all provider request/batch methods switch from alias helpers to redirect helpers; ListModels scopes by user path.
Factory and runtime refresh consolidation
internal/virtualmodels/factory.go, internal/app/runtime_refresh.go, internal/app/app_test.go
Two stop hooks collapsed to one stopRefresh; runtime refresh sequence replaces aliases+model_overrides steps with a single virtual_models step; virtualModelsService() helper replaces sub-service accessors.
Legacy seed ingestion from aliases and model_overrides
internal/virtualmodels/seed.go, internal/virtualmodels/seed_legacy.go, internal/virtualmodels/seed_test.go
seedFromLegacy switches from legacy store objects to backend-specific SQL/MongoDB direct readers; adds transactionalSeeder interface; rollbackPartialSeed for best-effort cleanup; tests for idempotency, collision, rollback, and missing tables.
Store UpsertAll for atomic batch seeding
internal/virtualmodels/store_sqlite.go, internal/virtualmodels/store_postgresql.go, internal/virtualmodels/store_test.go
UpsertAll added to both stores for transactional batch writes; upsert SQL extracted to shared constants; argument builders centralized; atomicity test added.
Gateway context-aware resolver dispatch and model listing
internal/gateway/interfaces.go, internal/gateway/request_model_resolution.go, internal/gateway/batch_selection.go, internal/server/exposed_model_lister.go, internal/server/handlers.go
New UserPathModelResolver/UserPathExposedModelLister interfaces; ctx threaded through ResolveExecutionSelector; resolveRequestedModel dispatches to ResolveModelForUserPath when available; ListModels prioritizes user-path-scoped exposed model branch.
Admin HTTP handlers and routes for /virtual-models
internal/admin/handler_virtualmodels.go, internal/admin/handler.go, internal/admin/handler_models.go, internal/admin/errors.go, internal/admin/routes.go, internal/admin/handler_usage.go
New ListVirtualModels/UpsertVirtualModel/DeleteVirtualModel handlers; Handler struct replaces alias/model-override service fields with virtualModels; modelAccessResponse.Override becomes *virtualmodels.VirtualModel; /virtual-models routes replace /aliases and /model-overrides.
Application wiring switched to virtualmodels service
internal/app/app.go
Drops alias/model-override imports; single vm for resolver, authorizer, batch preparer, guardrail executor, and admin handler initialization.
Legacy package removal
internal/aliases/factory.go, internal/modeloverrides/store_mongodb.go
Removes aliases factory/result type and model-overrides MongoDB store implementation.
Admin backend test migration and new virtual-model coverage
internal/admin/handler_virtualmodels_test.go, internal/admin/handler_models_test.go, internal/admin/handler_pricing_test.go, internal/admin/routes_test.go
New handler tests for list/upsert/delete/503/400/502/404 scenarios; model-access state tests validate disabled policy, provider-wide, and global overrides; pricing test wired to WithVirtualModels.
Service behavior tests for redirect and policy rules
internal/virtualmodels/service_test.go
Expands tests for resolution, user-path scoping, policy precedence (exact > provider-wide > global), FilterPublicModels, list views, and ErrNotFound on missing delete.
Server and model-validation tests updated to virtualmodels APIs
internal/server/handlers_test.go, internal/server/model_validation_test.go
Replaces aliases.NewService/aliases.NewProvider/aliases.NewBatchPreparer with virtualmodels equivalents across all affected server tests.
Dashboard data fetching and unified virtual-model state
internal/admin/dashboard/static/js/modules/virtual-models.js, internal/admin/dashboard/static/js/dashboard.js
Replaces aliasesAvailable/modelOverridesAvailable with virtualModelsAvailable; adds fetchVirtualModels, globalScopeRow, buildDisplayModels has_virtual_model tracking; rewires overlay/fetch orchestration/module factory to virtual-models module.
Dashboard virtual-model editor and toggle handlers
internal/admin/dashboard/static/js/modules/virtual-models.js
Unified openVirtualModelCreate/openVirtualModelEditAlias/openVirtualModelEditModel open flows; toggleAliasRow/toggleModelRow via /admin/virtual-models; submitVirtualModelForm/deleteVirtualModel persistence actions.
Dashboard templates and CSS for unified editor and toggles
internal/admin/dashboard/templates/..., internal/admin/dashboard/static/css/dashboard.css, internal/admin/dashboard/templates/layout.html
access-toggle template added; model-table-body row actions use virtual-model gating; page-models replaces two modals with single virtual-model editor; .alias-toggle.restricted CSS variant added; .model-access-state-badge removed; layout swaps aliases.js for virtual-models.js.
Dashboard test expectations updated for unified editor
internal/admin/dashboard/static/js/modules/virtual-models.test.cjs, internal/admin/dashboard/static/js/modules/aliases.test.cjs, internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs, internal/admin/dashboard/static/js/modules/timezone-layout.test.cjs, internal/admin/dashboard/dashboard_test.go
New 1035-line virtual-models.test.cjs suite; legacy alias tests migrated to virtual-model semantics; layout/timezone tests updated for vmFormOpen/vmFormError/close labels.
ADR completion note
docs/adr/0008-virtual-models.md
Adds update section documenting unified native engine, Enabled as authoritative policy gating, user_paths enforcement, and consolidated /admin/virtual-models endpoint.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant GatewayHandler
  participant ResolveExecutionSelector
  participant VirtualModelsService
  participant Provider

  Client->>GatewayHandler: POST /v1/chat/completions (model="smart")
  GatewayHandler->>ResolveExecutionSelector: ctx + requested selector
  ResolveExecutionSelector->>VirtualModelsService: ResolveModelForUserPath(ctx, "smart")
  VirtualModelsService->>VirtualModelsService: snapshot.resolveRedirect("smart", catalog, userPath)
  VirtualModelsService-->>ResolveExecutionSelector: resolved="openai/gpt-4o", changed=true
  ResolveExecutionSelector-->>GatewayHandler: resolved selector
  GatewayHandler->>VirtualModelsService: ValidateModelAccess(ctx, resolved)
  VirtualModelsService-->>GatewayHandler: nil (allowed)
  GatewayHandler->>Provider: ChatCompletion(rewritten request, model="gpt-4o")
  Provider-->>GatewayHandler: response
  GatewayHandler-->>Client: 200 OK
Loading
sequenceDiagram
  participant AdminUI
  participant DashboardJS
  participant AdminAPI
  participant VirtualModelsService
  participant Store

  AdminUI->>DashboardJS: fetchVirtualModels()
  DashboardJS->>AdminAPI: GET /admin/virtual-models
  AdminAPI->>VirtualModelsService: ListViews()
  VirtualModelsService-->>AdminAPI: []View (redirects + policies)
  AdminAPI-->>DashboardJS: 200 []View
  DashboardJS->>DashboardJS: applyVirtualModelViews(views)

  AdminUI->>DashboardJS: openVirtualModelCreate()
  DashboardJS->>DashboardJS: vmFormOpen=true, vmFormMode="create"
  AdminUI->>DashboardJS: submitVirtualModelForm()
  DashboardJS->>AdminAPI: PUT /admin/virtual-models {source, target_model, enabled}
  AdminAPI->>VirtualModelsService: Upsert(ctx, VirtualModel)
  VirtualModelsService->>Store: Upsert(vm)
  Store-->>VirtualModelsService: nil
  VirtualModelsService-->>AdminAPI: view
  AdminAPI-->>DashboardJS: 200 View
  DashboardJS->>DashboardJS: fetchVirtualModels() + fetchModels()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • ENTERPILOT/GoModel#147: This PR removes the legacy alias subsystem and admin endpoints introduced in #147, rewiring all provider/dashboard/model-validation paths to the unified virtualmodels engine.
  • ENTERPILOT/GoModel#218: Directly related — this PR removes the DB-backed internal/modeloverrides admin/runtime wiring introduced in #218 and replaces it with the unified internal/virtualmodels implementation.
  • ENTERPILOT/GoModel#357: Both PRs modify internal/gateway/request_model_resolution.go#357 adds provider refresh-and-retry while this PR adds ctx threading and UserPathModelResolver dispatch in the same function.

Poem

🐇 Hop, hop, the old paths are gone,
Two packages became one at dawn!
aliases and overrides, farewell,
One engine now rings the model bell.
VirtualModels unify the way—
This bunny ships clean code today! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.13% 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 title accurately summarizes the main change: unifying the virtual-models engine, admin API, and dashboard UI into a single cohesive system, replacing separate alias and model-override subsystems.
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/unifying-ui-for-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.

@mintlify

mintlify Bot commented Jun 23, 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 23, 2026, 8:51 AM

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

@codecov-commenter

codecov-commenter commented Jun 23, 2026

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Reviews (4): Last reviewed commit: "fix(virtualmodels): scope provider ListM..." | Re-trigger Greptile

Comment thread internal/virtualmodels/factory.go
Comment thread internal/virtualmodels/seed.go Outdated
Comment thread internal/virtualmodels/resolve.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: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/gateway/request_model_resolution.go (1)

215-216: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use the scoped resolver in the provider-owned branch too.

The explicit resolver path is now ctx-aware, but this branch still calls ResolveModel directly. When a redirect-aware provider/decorator owns resolution, user_paths are ignored during gateway selection. Reuse resolveRequestedModel(ctx, providerResolver, requested) here and ensure redirect-aware providers implement ResolveModelForUserPath.

🔒 Proposed fix
 	if providerResolver, ok := provider.(ModelResolver); ok {
-		providerSelector, providerChanged, err := providerResolver.ResolveModel(requested)
+		providerSelector, providerChanged, err := resolveRequestedModel(ctx, providerResolver, requested)
 		if err != nil {
 			if resolvedSelector != (core.ModelSelector{}) {
 				// Preserve alias targets so callers can refresh the concrete provider before retrying.
🤖 Prompt for 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.

In `@internal/gateway/request_model_resolution.go` around lines 215 - 216, The
code in the ModelResolver type assertion branch is calling ResolveModel directly
on the providerResolver, which bypasses the context-aware scoped resolution.
Replace the direct call to providerResolver.ResolveModel(requested) with the
scoped resolver function resolveRequestedModel(ctx, providerResolver, requested)
to ensure user_paths are properly handled during gateway selection.
Additionally, verify that any redirect-aware providers implementing the
ModelResolver interface also implement the ResolveModelForUserPath method as
required by the scoped resolver.
internal/virtualmodels/seed_test.go (1)

141-145: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Assert collision failures leave no partial seed rows.

This test passes if seedFromLegacy inserts one legacy row and then returns the collision error, which would leave the rollback-safety migration in a partially applied state. Check the virtual model store remains empty after the error.

Test hardening
 	if err := seedFromLegacy(ctx, vmStore, conn); err == nil {
 		t.Fatalf("seedFromLegacy() error = nil, want migration conflict error")
 	}
+	got, err := vmStore.List(ctx)
+	if err != nil {
+		t.Fatalf("List() after failed seed error = %v", err)
+	}
+	if len(got) != 0 {
+		t.Fatalf("failed seed left %d virtual model row(s): %#v", len(got), got)
+	}

As per coding guidelines, tests should cover error handling.

🤖 Prompt for 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.

In `@internal/virtualmodels/seed_test.go` around lines 141 - 145, The test
currently only verifies that seedFromLegacy returns an error, but does not check
that the virtual model store remains empty after the error occurs. Add an
assertion after the error check to verify that vmStore contains no rows or
entries, ensuring that the collision error prevents partial seed data from being
left in the system. This will properly test that the migration is atomic and
leaves no partial state when encountering the collision error.

Source: Coding guidelines

🤖 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/admin/dashboard/static/js/modules/aliases.js`:
- Line 624: The logic for computing vmFormEffectiveEnabled at line 624
incorrectly treats the presence of an override as true even when
override.enabled is explicitly false. The current expression Boolean(override &&
override.enabled !== false) || defaultEnabled uses override.enabled !== false
which treats undefined as true. Instead, check if an override exists and use its
enabled value directly; if override exists and has an enabled property, use that
value; otherwise fall back to defaultEnabled. This ensures that an override with
enabled set to false is properly respected and takes precedence over the default
value.
- Around line 799-808: The toggleRowEnabled method dispatches write operations
via toggleAliasRow and toggleModelRow without checking if the feature is
currently available, which leads to repeated failing requests when the service
is in an unavailable state. Add a guard condition at the beginning of the
toggleRowEnabled method to check if virtual models are available before
proceeding with either the row.is_alias check and toggleAliasRow call or the
toggleModelRow call, and return early if the feature is unavailable.
- Around line 1052-1072: The confirmation check for overwriting existing aliases
is skipped when targetModel is empty because the condition checks both
vmFormMode !== 'edit' AND targetModel together. This allows silent overwrites of
existing redirect/policy rows in create mode with an empty target_model.
Restructure the logic to separate the checks: first verify if we are in create
mode and have a source, then check for existing aliases using
findExistingAliasByName regardless of whether targetModel is set, ensuring users
always get confirmation before overwriting an existing alias or policy
regardless of the target_model value.

In `@internal/admin/dashboard/templates/page-models.html`:
- Around line 130-132: The Save button (virtual-model-submit-btn) at line 131
only checks vmSubmitting in its disabled state, but does not account for
vmDeleting which is set by the Remove button. Update the :disabled binding on
the submit button to include vmDeleting in addition to vmSubmitting, so the Save
button is disabled whenever either a save or delete operation is in progress,
preventing concurrent submissions.

In `@internal/admin/handler_usage.go`:
- Around line 409-410: The virtual model selector resolution in the ResolveModel
method call is using an empty string for the user path parameter when
constructing the RequestedModelSelector, which prevents proper path-restricted
redirect resolution. Replace the empty string "" passed to
core.NewRequestedModelSelector with the actual user_path variable that should be
available in this handler context to ensure scoped redirects are correctly
resolved and the appropriate provider/model combination is selected.

In `@internal/virtualmodels/provider.go`:
- Around line 473-493: The redirect resolution helper functions
resolveRedirectModel, resolveRedirectRequestSelector, and
resolveRedirectRoutableSelector are currently using the unscoped
Service.ResolveModel method, which does not respect user_path scoping. Add a ctx
context.Context parameter to all three functions, and update the call to
service.ResolveModel to use service.ResolveModelForUserPath(ctx, requested)
instead. This will ensure request-time redirects are properly scoped to user
paths. Update all callers of these functions to pass the request context.

In `@internal/virtualmodels/resolve.go`:
- Around line 85-113: The exposedModelsFiltered method does not check
entry.vm.UserPaths when building the result list, allowing redirects scoped to
specific paths like /team to appear in context-free listings such as
ExposedModels. Add a condition in the exposedModelsFiltered function to skip
entries where entry.vm.UserPaths is non-empty, ensuring that scoped redirects
are not exposed when there is no user path context available to match against.
This check should occur after the Enabled check and before appending to the
result list.

In `@internal/virtualmodels/seed_test.go`:
- Around line 85-87: Add explicit assertions after the migration logic completes
in the idempotency test to verify that the Enabled semantics are properly
handled. Specifically, add assertions to confirm that the enabled alias created
via insertLegacyAlias with enabled=true remains enabled after migration, and
that the legacy override created via insertLegacyOverride becomes an enabled
restricted policy. Additionally, add a test case with a disabled legacy alias
(by inserting an alias with enabled=false) to ensure that disabled aliases are
properly handled during migration and that regressions in this case would be
caught by the test.

In `@internal/virtualmodels/seed.go`:
- Around line 52-61: The seed function currently writes aliases to the store via
store.Upsert before validating override conflicts, which means if an error
occurs during override processing, aliases are already persisted and seeding
gets skipped on retry. Restructure the logic to validate all legacy aliases and
overrides for conflicts before making any store.Upsert calls; once validation
completes successfully, then perform all write operations. If the store supports
transactions, wrap both the legacyAliasRows loop and legacyOverrideRows loop
write operations within a single transaction to ensure atomicity and prevent
partial migrations.

---

Outside diff comments:
In `@internal/gateway/request_model_resolution.go`:
- Around line 215-216: The code in the ModelResolver type assertion branch is
calling ResolveModel directly on the providerResolver, which bypasses the
context-aware scoped resolution. Replace the direct call to
providerResolver.ResolveModel(requested) with the scoped resolver function
resolveRequestedModel(ctx, providerResolver, requested) to ensure user_paths are
properly handled during gateway selection. Additionally, verify that any
redirect-aware providers implementing the ModelResolver interface also implement
the ResolveModelForUserPath method as required by the scoped resolver.

In `@internal/virtualmodels/seed_test.go`:
- Around line 141-145: The test currently only verifies that seedFromLegacy
returns an error, but does not check that the virtual model store remains empty
after the error occurs. Add an assertion after the error check to verify that
vmStore contains no rows or entries, ensuring that the collision error prevents
partial seed data from being left in the system. This will properly test that
the migration is atomic and leaves no partial state when encountering the
collision error.
🪄 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: 7b8ebd52-2d41-490a-8060-d2dc9fc32fc3

📥 Commits

Reviewing files that changed from the base of the PR and between fa650be and d17b373.

📒 Files selected for processing (66)
  • docs/adr/0008-virtual-models.md
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/dashboard.js
  • internal/admin/dashboard/static/js/modules/aliases.js
  • internal/admin/dashboard/static/js/modules/aliases.test.cjs
  • internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs
  • internal/admin/dashboard/static/js/modules/timezone-layout.test.cjs
  • internal/admin/dashboard/templates/model-table-body.html
  • internal/admin/dashboard/templates/page-models.html
  • internal/admin/errors.go
  • internal/admin/handler.go
  • internal/admin/handler_aliases.go
  • internal/admin/handler_aliases_test.go
  • internal/admin/handler_model_overrides.go
  • internal/admin/handler_model_overrides_test.go
  • internal/admin/handler_models.go
  • internal/admin/handler_models_test.go
  • internal/admin/handler_pricing_test.go
  • internal/admin/handler_usage.go
  • internal/admin/handler_virtualmodels.go
  • internal/admin/handler_virtualmodels_test.go
  • internal/admin/routes.go
  • internal/admin/routes_test.go
  • internal/aliases/batch_preparer.go
  • internal/aliases/batch_preparer_test.go
  • internal/aliases/factory.go
  • internal/aliases/provider_test.go
  • internal/aliases/service.go
  • internal/aliases/service_test.go
  • internal/aliases/store.go
  • internal/aliases/store_mongodb.go
  • internal/aliases/store_postgresql.go
  • internal/aliases/store_sqlite.go
  • internal/aliases/types.go
  • internal/app/app.go
  • internal/app/app_test.go
  • internal/app/runtime_refresh.go
  • internal/gateway/batch_selection.go
  • internal/gateway/interfaces.go
  • internal/gateway/request_model_resolution.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/store_sqlite_test.go
  • internal/modeloverrides/types.go
  • internal/server/handlers_test.go
  • internal/server/model_validation_test.go
  • internal/virtualmodels/adapters.go
  • internal/virtualmodels/authorize.go
  • internal/virtualmodels/batch_preparer.go
  • internal/virtualmodels/factory.go
  • internal/virtualmodels/provider.go
  • internal/virtualmodels/resolve.go
  • internal/virtualmodels/seed.go
  • internal/virtualmodels/seed_legacy.go
  • internal/virtualmodels/seed_test.go
  • internal/virtualmodels/service.go
  • internal/virtualmodels/service_test.go
  • internal/virtualmodels/snapshot.go
  • internal/virtualmodels/types.go
  • internal/virtualmodels/validation.go
💤 Files with no reviewable changes (26)
  • internal/aliases/provider_test.go
  • internal/aliases/types.go
  • internal/aliases/store_postgresql.go
  • internal/modeloverrides/store_sqlite_test.go
  • internal/modeloverrides/store_sqlite.go
  • internal/modeloverrides/store_postgresql.go
  • internal/admin/handler_aliases_test.go
  • internal/modeloverrides/factory.go
  • internal/aliases/store.go
  • internal/admin/handler_aliases.go
  • internal/aliases/batch_preparer.go
  • internal/admin/handler_model_overrides_test.go
  • internal/aliases/batch_preparer_test.go
  • internal/virtualmodels/adapters.go
  • internal/modeloverrides/batch_preparer.go
  • internal/modeloverrides/types.go
  • internal/aliases/service.go
  • internal/aliases/store_mongodb.go
  • internal/modeloverrides/service_test.go
  • internal/aliases/store_sqlite.go
  • internal/aliases/service_test.go
  • internal/modeloverrides/store_mongodb.go
  • internal/admin/handler_model_overrides.go
  • internal/modeloverrides/store.go
  • internal/aliases/factory.go
  • internal/modeloverrides/service.go

Comment thread internal/admin/dashboard/static/js/modules/aliases.js Outdated
Comment thread internal/admin/dashboard/static/js/modules/virtual-models.js
Comment thread internal/admin/dashboard/static/js/modules/aliases.js Outdated
Comment thread internal/admin/dashboard/templates/page-models.html
Comment thread internal/admin/handler_usage.go
Comment thread internal/virtualmodels/provider.go Outdated
Comment thread internal/virtualmodels/resolve.go
Comment thread internal/virtualmodels/seed_test.go
Comment thread internal/virtualmodels/seed.go
…oped 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>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Thanks @greptile-apps — all three findings addressed in 8957e18:

  • Policy refresh slows down (P1) — the unified refresh loop now uses the shorter of the model-cache and workflow intervals, restoring the policy side's faster cross-instance propagation (was lagging up to the model-cache interval).
  • Seed can stay partial (P1) — collisions are now detected before any write, so a conflict can't leave virtual_models partially seeded (which the empty-table guard would then skip next startup). Test asserts the table is empty after a conflict.
  • Scoped aliases listed globally (P2) — added ExposedModelsForUserPath (hides redirects scoped to user_paths the caller doesn't match); /v1/models prefers it via the optional UserPathExposedModelLister.

go build/vet/golangci-lint/go test ./... green (57 packages); added tests for the scoped-listing and the no-partial-seed behavior.

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown

All three fixes look solid. Here's my assessment:

P1 — Refresh cadence (factory.go): The fix correctly takes min(modelInterval, policyInterval) with sensible defaults (1h for model-cache, 1m for policy/workflow). One minor note worth a sanity check: cfg.Cache.Model.RefreshInterval is multiplied by time.Second (so it's in seconds), but cfg.Workflows.RefreshInterval is used as-is — if that field is already a time.Duration (nanoseconds), the logic is correct; if it's an integer seconds field like the other, it needs the same * time.Second conversion. Worth confirming the types match.

P1 — Seed atomicity (seed.go): The two-phase approach (resolve all rows → detect collisions → write nothing until clean) is exactly right. The collision error message is actionable (names both the alias source and the override selector), and the test asserting an empty table after a conflict closes the original len(existing) > 0 bypass.

P2 — Scoped alias listing (resolve.go / handlers.go / exposed_model_lister.go): Clean. ExposedModelsForUserPath composes naturally with the existing filter chain, and handlers.go uses the correct precedence: UserPathExposedModelLister > FilteredExposedModelLister > ExposedModelLister. TestService_ExposedModelsForUserPathHidesScopedRedirects covers matching caller, non-matching caller, and backward-compat with ExposedModelsFiltered — that's the full set of cases.

The cfg.Workflows.RefreshInterval type question aside, all three are addressed correctly. LGTM.

@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/server/handlers.go`:
- Around line 417-423: The UserPathExposedModelLister type check in the handler
is coupled with the h.modelAuthorizer nil check, meaning scoped user-path
listing only occurs when an authorizer exists. Decouple these checks by removing
the h.modelAuthorizer != nil condition from the UserPathExposedModelLister
branch so that scoped filtering always executes when available, and modify the
authorization filter function passed to ExposedModelsForUserPath to handle the
case where h.modelAuthorizer is nil (returning true to allow all models when no
authorizer is present). Apply the same decoupling logic to the
FilteredExposedModelLister branch to ensure consistent behavior.
🪄 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: 46126531-c41b-4b00-93cd-f743d2b019eb

📥 Commits

Reviewing files that changed from the base of the PR and between d17b373 and 8957e18.

📒 Files selected for processing (8)
  • internal/server/exposed_model_lister.go
  • internal/server/handlers.go
  • internal/virtualmodels/factory.go
  • internal/virtualmodels/resolve.go
  • internal/virtualmodels/seed.go
  • internal/virtualmodels/seed_test.go
  • internal/virtualmodels/service.go
  • internal/virtualmodels/service_test.go

Comment thread internal/server/handlers.go Outdated
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>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Addressed @coderabbitai's findings in ebcd3c0 (the two it overlapped with @greptile-apps — seed prevalidation and exposed-redirect user_path gating — were already in 8957e18):

Fixed

  • provider.go — request-time redirect rewrites now user-path aware (Major/security): the shared Provider/BatchPreparer rewrite path used unscoped ResolveModel, so a user_paths-scoped alias could apply for callers outside its scope in batch and guardrail-executor requests (the main chat path was already scoped via the gateway). Threaded ctx and switched request-time rewrites to ResolveModelForUserPath; unscoped ResolveModel is kept only for inventory.
  • aliases.js:1072 — create-mode overwrite confirmation now runs even with an empty target (a policy upsert that could otherwise silently replace a row).
  • aliases.js:624vmFormEffectiveEnabled reflects the override's value, not its presence.
  • aliases.js:808toggleRowEnabled bails when virtual models are unavailable.
  • page-models.html:131 — Save is disabled while Remove is in progress.
  • seed_test.go — asserts migrated Enabled semantics, incl. a disabled-alias case.

Not changed (with reason)

  • handler_usage.go:410 — 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.

go build/vet/golangci-lint (0 issues) / go test ./... (57 pkg) / node --test (318) all green.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Tip

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

@SantiagoDePolonia Thanks for the thorough walkthrough. All six fixes look correct, and the rationale for leaving handler_usage.go:410 unchanged is sound — pricing recalculation is an operator-driven action with no request user_path in context, so unscoped resolution is exactly right there (scoped resolution would silently fall through to the literal model name, which is the wrong behavior for that call site).

The security fix in provider.go is the most important item here, and threading ctx through to ResolveModelForUserPath closes the gap where user_paths-scoped aliases could leak to unmatched callers in batch and guardrail-executor paths.

Everything looks good — feel free to proceed with merging. 🐇✨

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown

The fix pass looks thorough. A few notes on each item:

Confirmed valid reasoning — handler_usage.go:410: The justification holds. The pricing recalculation context is operator-supplied (not a live request user path), and v1 redirects are single-target by definition, so ResolveModel with an empty user path will always resolve to the correct concrete model. Using ResolveModelForUserPath there would cause a spurious fall-through to the literal name. No change needed.

Two of my original findings remain open:

  1. Policy refresh rate (factory.go) — the unified loop still runs on CACHE_MODEL_REFRESH_INTERVAL (default 3600s). Not blocking merge, but worth tracking: in multi-instance deploys a disable/restrict action on one node won't propagate to others for up to an hour. If you intend to address it post-merge, worth adding a TODO or issue.

  2. Scoped aliases in /v1/models (resolve.go:116) — acknowledged in the PR notes as a follow-up ("Filtering the listing by per-redirect user_paths is a small follow-up"). That's fine; the resolution/enforcement path is correct, so this is an information-leakage issue only (a caller sees a model ID they can't use, not a security bypass). Tracking it as a follow-up issue is the right call.

Everything else looks addressed. The provider.go ctx-threading fix for batch/guardrail rewrites is the most important security fix in this pass — clean implementation.

Comment thread internal/admin/dashboard/static/js/modules/aliases.js Outdated
Comment thread internal/virtualmodels/seed.go Outdated

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/admin/dashboard/static/js/modules/aliases.js (1)

884-891: 🎯 Functional Correctness | 🟠 Major

Enable toggle silently fails when the default is disabled.

In the enable branch (desired === true), an existing policy with empty user_paths always triggers a DELETE, reverting the source to inherited/default behavior. When default_enabled is false (e.g., MODELS_ENABLED_BY_DEFAULT=false, or an inherited provider/global disable), deleting the explicit policy leaves the model disabled even though the operator just toggled it on — yet line 919 still reports 'Model enabled.'. Only PUT enabled:true reliably enables in that case.

Proposed fix
-                } else if (existingPolicy && existingPaths.length === 0) {
-                    method = 'DELETE';
-                    payload = { source: selector };
+                } else if (existingPolicy && existingPaths.length === 0 && access.default_enabled !== false) {
+                    method = 'DELETE';
+                    payload = { source: selector };
                 } else {
                     payload = { source: selector, enabled: true, user_paths: existingPaths };
                 }
🤖 Prompt for 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.

In `@internal/admin/dashboard/static/js/modules/aliases.js` around lines 884 -
891, The enable toggle fails when the default is disabled because the condition
`existingPolicy && existingPaths.length === 0` triggers a DELETE operation even
in the enable case (desired === true), which reverts the source to
inherited/default behavior. When default_enabled is false, this leaves the model
disabled despite the user toggling it on. Fix this by restructuring the
conditional logic to ensure that when desired === true, you always send a PUT
request with enabled:true instead of using DELETE, regardless of whether
existingPaths is empty. This will explicitly enable the model and work correctly
in all default_enabled scenarios.
🤖 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.

Outside diff comments:
In `@internal/admin/dashboard/static/js/modules/aliases.js`:
- Around line 884-891: The enable toggle fails when the default is disabled
because the condition `existingPolicy && existingPaths.length === 0` triggers a
DELETE operation even in the enable case (desired === true), which reverts the
source to inherited/default behavior. When default_enabled is false, this leaves
the model disabled despite the user toggling it on. Fix this by restructuring
the conditional logic to ensure that when desired === true, you always send a
PUT request with enabled:true instead of using DELETE, regardless of whether
existingPaths is empty. This will explicitly enable the model and work correctly
in all default_enabled scenarios.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49420b45-af64-42e5-a7e4-101e62b1a9a9

📥 Commits

Reviewing files that changed from the base of the PR and between 8957e18 and ebcd3c0.

📒 Files selected for processing (6)
  • internal/admin/dashboard/static/js/modules/aliases.js
  • internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs
  • internal/admin/dashboard/templates/page-models.html
  • internal/virtualmodels/batch_preparer.go
  • internal/virtualmodels/provider.go
  • internal/virtualmodels/seed_test.go

…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>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Addressed the latest re-review findings in 81136a3:

  • handlers.go — exposure scoping decoupled from the authorizer (@coderabbitai, Major): UserPathExposedModelLister now runs even when modelAuthorizer is nil (passing a nil target-allow), so a user_paths-scoped redirect is hidden from out-of-scope callers instead of falling back to unscoped listing.
  • aliases.js — enable toggle in default-disabled deployments (@greptile-apps, P1): enabling a model only DELETEs a path-less policy when default_enabled !== false; otherwise it PUTs enabled: true, so the model is actually enabled rather than falling back to the disabled default while the UI says enabled.
  • seed.go — partial-write rollback (@greptile-apps, 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 can't trip the len(existing) > 0 guard and skip importing the rest next startup. Test added (TestSeedFromLegacy_RollsBackPartialWriteOnError).

go build/vet/golangci-lint (0 issues) / go test ./... (57 pkg) / node --test (318) all green.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/virtualmodels/seed.go (1)

60-81: 🔒 Security & Privacy | 🔴 Critical

Apply source normalization before collision detection to match Store.Upsert behavior.

The collision detection in seedFromLegacy (lines 60-81) uses raw vm.Source values from alias.toRedirect() and override.toPolicy(), but all three Store.Upsert implementations normalize the source via strings.TrimSpace() before persisting. This creates a mismatch: an alias with Source="foo " (trailing space) and an override with Source="foo" would both pass the collision check (different raw keys), but collide on the same normalized store key "foo". The override—containing an access control—would then be silently overwritten, exactly the failure this check aims to prevent.

Normalize sources in the seen map using the same strings.TrimSpace() that Store.Upsert applies, or wrap the legacy rows with normalizeRedirect()/normalizeStoredPolicy() before the collision check.

🤖 Prompt for 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.

In `@internal/virtualmodels/seed.go` around lines 60 - 81, The collision detection
in seedFromLegacy uses raw vm.Source values from alias.toRedirect() and
override.toPolicy(), but Store.Upsert normalizes sources using
strings.TrimSpace() before persisting. This mismatch allows sources with
whitespace differences (like "foo " and "foo") to bypass the collision check but
collide in the actual store, silently overwriting the access control override.
Normalize each vm.Source using strings.TrimSpace() when adding to the seen map
to match the normalization behavior in Store.Upsert, ensuring the collision
detection catches all actual conflicts.
🤖 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.

Outside diff comments:
In `@internal/virtualmodels/seed.go`:
- Around line 60-81: The collision detection in seedFromLegacy uses raw
vm.Source values from alias.toRedirect() and override.toPolicy(), but
Store.Upsert normalizes sources using strings.TrimSpace() before persisting.
This mismatch allows sources with whitespace differences (like "foo " and "foo")
to bypass the collision check but collide in the actual store, silently
overwriting the access control override. Normalize each vm.Source using
strings.TrimSpace() when adding to the seen map to match the normalization
behavior in Store.Upsert, ensuring the collision detection catches all actual
conflicts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ca965fb7-6c94-4a25-9264-a6abf1417f67

📥 Commits

Reviewing files that changed from the base of the PR and between ebcd3c0 and 81136a3.

📒 Files selected for processing (4)
  • internal/admin/dashboard/static/js/modules/aliases.js
  • internal/server/handlers.go
  • internal/virtualmodels/seed.go
  • internal/virtualmodels/seed_test.go

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

@SantiagoDePolonia All three fixes look solid:

  • handlers.go: Decoupling UserPathExposedModelLister from modelAuthorizer is the right call — passing nil as the allow function means all redirect targets pass the inventory filter while user-path scoping still gates which redirects are visible to each caller. The previous guard meant a nil authorizer would silently fall back to unscoped listing and expose everything.
  • aliases.js: The default_enabled !== false guard for the DELETE path is correct — in a default-disabled deployment a DELETE would leave the model relying on the (disabled) default, so an explicit PUT enabled: true is necessary.
  • seed.go: Best-effort rollback on partial write is a good safety net; using a fresh context for rollback avoids cancellation propagation blocking the cleanup.

Everything looks good — feel free to merge. 🐇✨

✅ Action performed

Comments resolved. Approval is disabled; enable reviews.request_changes_workflow to allow explicit top-level @coderabbitai resolve or @coderabbitai approve commands.

…ss 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>
Comment thread internal/virtualmodels/provider.go Outdated
Comment thread internal/virtualmodels/seed.go Outdated
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>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Addressed the latest re-review in 32fb69e:

  • provider.go — scope listing leak (P1/security): Provider.ListModels merged exposed redirects with the unscoped ExposedModels(), leaking user_paths-scoped redirect IDs to callers outside their scope. It has ctx, so it now uses ExposedModelsForUserPath(core.UserPathFromContext(ctx), nil) — parity with the server /v1/models path. (Applied your suggestion.)
  • seed.go — atomic write (P1): the legacy seed now writes atomically on the SQL backends. SQLiteStore/PostgreSQLStore implement an optional transactional UpsertAll, and the seed prefers it, so a failed seed leaves the table untouched rather than partially populated (no len(existing) > 0 suppression). MongoDB — no cross-backend transaction without a replica set — keeps the per-row best-effort rollback fallback. Added TestSQLiteStore_UpsertAllIsAtomic (success commits all; a cancelled-context batch writes nothing).

go build/vet/golangci-lint (0 issues) / go test ./... (57 pkg) / node --test (318) all green.

On the residual MongoDB case: a fully atomic Mongo seed needs a session transaction (replica set), which isn't always available; the best-effort rollback + the fact that a seed error fails startup (so the process won't serve partial data on that run) is the pragmatic floor for this REMOVE-LATER migration. Happy to add a Mongo session-transaction path if you'd prefer.

@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Confirmed the type question from the review on the factory.go cadence fix: config.Workflows.RefreshInterval is a time.Duration (config/workflows.go:9, default time.Minute), while Cache.Model.RefreshInterval is int seconds (config/cache.go:20). The fix converts the seconds field (* time.Second) and uses the workflow field as-is, so min(modelInterval, policyInterval) compares like units — correct (and it mirrors the pre-collapse code). No change needed.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/admin/dashboard/static/js/modules/aliases.js (1)

913-925: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not DELETE an exact policy when a broader policy would still restrict the row.

When enabling a path-less exact policy, this branch deletes it whenever defaults are enabled. If a provider/global policy is disabled or restricted, deleting the exact row falls back to that broader policy, so the toggle can say “Model enabled” while the row remains disabled/restricted. Keep an explicit enabled: true policy unless the inherited state already allows all.

Based on PR context, virtual-model policies use exact/provider/global precedence, and DELETE only removes the exact source row.

Suggested fix
                 const existingPaths = existingPolicy && Array.isArray(existingPolicy.user_paths)
                     ? existingPolicy.user_paths
                     : [];
+                const providerSelector = row.provider_name ? this.providerOverrideSelector(row.provider_name) : '';
+                const providerPolicy = providerSelector && providerSelector !== selector
+                    ? this.findModelOverrideView(providerSelector)
+                    : null;
+                const globalPolicy = selector !== this.globalOverrideSelector()
+                    ? this.findModelOverrideView(this.globalOverrideSelector())
+                    : null;
+                const inheritedPolicy = providerPolicy || globalPolicy;
+                const inheritedAllowsAll = !inheritedPolicy
+                    || (inheritedPolicy.enabled !== false && !this.modelAccessUserPathsRestrict(inheritedPolicy.user_paths));
 
                 this.rowTogglingKey = row.key;
                 this.aliasError = '';
                 this.aliasNotice = '';
 
                 let method = 'PUT';
                 let payload;
                 if (desired === false) {
                     payload = { source: selector, enabled: false, user_paths: existingPaths };
-                } else if (existingPolicy && existingPaths.length === 0 && access.default_enabled !== false) {
+                } else if (existingPolicy && existingPaths.length === 0 && access.default_enabled !== false && inheritedAllowsAll) {
                     // Removing a path-less policy only enables the model when the
                     // default is on; in a default-disabled deployment we must keep an
                     // explicit enabled policy instead of falling back to the default.
                     method = 'DELETE';
                     payload = { source: selector };
                 } else {
🤖 Prompt for 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.

In `@internal/admin/dashboard/static/js/modules/aliases.js` around lines 913 -
925, The DELETE condition in the path-less policy handling block only checks if
access.default_enabled is not false, but it fails to account for broader
policies (provider/global level) that might still restrict the row. When
deleting the exact policy, if a broader policy is disabled or restricted, the
row will fall back to that restricted state while the toggle incorrectly shows
as enabled. Fix this by also verifying that broader policies would allow access
before proceeding with the DELETE operation. If broader policies would restrict
access, use the else branch instead to keep an explicit enabled: true policy for
the selector source, ensuring the row remains properly enabled and not
restricted by inherited policies.
🤖 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/store_postgresql.go`:
- Line 155: The deferred transaction rollback in the anonymous function at line
155 is using the seeding context which may be canceled after Begin is called,
causing the rollback to fail and leaving connection/transaction cleanup to pgx.
Replace the seeding context parameter in the tx.Rollback(ctx) call with a fresh
context obtained from rollbackContext() to match the existing rollback pattern
used in service.go, ensuring proper cleanup even if the original context is
canceled.

In `@internal/virtualmodels/store_test.go`:
- Around line 109-116: The current test for the cancelled context in the
UpsertAll function only cancels the context before any transaction begins, which
does not actually test whether rollback occurs after a partial write succeeds.
Modify the test to add a mid-batch failure scenario where the first row in the
vms batch is successfully written but a subsequent row fails (for example, by
introducing a constraint violation or validation error on the second row), then
verify that the entire transaction is rolled back and the table remains empty.
This will ensure the test catches missing rollback logic in UpsertAll when
failures occur after partial writes.

---

Outside diff comments:
In `@internal/admin/dashboard/static/js/modules/aliases.js`:
- Around line 913-925: The DELETE condition in the path-less policy handling
block only checks if access.default_enabled is not false, but it fails to
account for broader policies (provider/global level) that might still restrict
the row. When deleting the exact policy, if a broader policy is disabled or
restricted, the row will fall back to that restricted state while the toggle
incorrectly shows as enabled. Fix this by also verifying that broader policies
would allow access before proceeding with the DELETE operation. If broader
policies would restrict access, use the else branch instead to keep an explicit
enabled: true policy for the selector source, ensuring the row remains properly
enabled and not restricted by inherited policies.
🪄 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: 61f6565f-38ef-40b5-b655-833d3e242682

📥 Commits

Reviewing files that changed from the base of the PR and between 81136a3 and 32fb69e.

📒 Files selected for processing (10)
  • internal/admin/dashboard/static/js/modules/aliases.js
  • internal/admin/dashboard/static/js/modules/aliases.test.cjs
  • internal/admin/dashboard/templates/access-toggle.html
  • internal/admin/dashboard/templates/model-table-body.html
  • internal/admin/dashboard/templates/page-models.html
  • internal/virtualmodels/provider.go
  • internal/virtualmodels/seed.go
  • internal/virtualmodels/store_postgresql.go
  • internal/virtualmodels/store_sqlite.go
  • internal/virtualmodels/store_test.go
💤 Files with no reviewable changes (1)
  • internal/admin/dashboard/static/js/modules/aliases.test.cjs

Comment thread internal/virtualmodels/store_postgresql.go Outdated
Comment thread internal/virtualmodels/store_test.go Outdated
SantiagoDePolonia and others added 2 commits June 23, 2026 14:35
…le, 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>
…ch 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>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Addressed @coderabbitai's review of the UpsertAll work (19e6c09):

  • store_postgresql.go — fresh rollback context: PostgreSQLStore.UpsertAll now defers tx.Rollback with a fresh rollbackContext() instead of the (possibly-canceled) seed context, matching the pattern in service.go. (SQLite's tx.Rollback() takes no context, so it's unaffected.)
  • store_test.go — real mid-batch rollback: replaced the pre-BeginTx cancelled-context case (which never exercised rollback after a successful row) with a true mid-batch failure — row 1 is written inside the transaction, row 2 fails to encode (non-finite Weight), and the test asserts the table is left empty.

Also in this push (separate commit 3b16799, the requested cleanups): unified the refresh cadence on Cache.Model.RefreshInterval, renamed the dashboard controller aliases.jsvirtual-models.js (+ factory/flag collapse), and removed the dead model-access-state-badge CSS.

go build/vet/golangci-lint (0 issues) / go test ./... (57 pkg) / node --test (318) all green.

aliases: [],
models: [],
modelOverrideViews: [],
virtualModelsAvailable: true,
aliases: [],
models: [],
modelOverrideViews: [],
virtualModelsAvailable: true,

@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/factory.go`:
- Around line 104-107: The comment in the virtual models factory section
references an incorrect environment variable name CACHE_MODEL_REFRESH_INTERVAL
when the actual config field uses CACHE_REFRESH_INTERVAL. Update the referenced
environment variable name in the comment that discusses the unified store
refresh cadence and operator tuning to use the correct env var name
CACHE_REFRESH_INTERVAL instead of CACHE_MODEL_REFRESH_INTERVAL to prevent
operator confusion during runtime configuration.
🪄 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: 7c7d2c28-1f9f-433f-8ee1-73dc1ccfb7a2

📥 Commits

Reviewing files that changed from the base of the PR and between 32fb69e and 19e6c09.

📒 Files selected for processing (9)
  • internal/admin/dashboard/dashboard_test.go
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/dashboard.js
  • internal/admin/dashboard/static/js/modules/virtual-models.js
  • internal/admin/dashboard/static/js/modules/virtual-models.test.cjs
  • internal/admin/dashboard/templates/layout.html
  • internal/virtualmodels/factory.go
  • internal/virtualmodels/store_postgresql.go
  • internal/virtualmodels/store_test.go
💤 Files with no reviewable changes (1)
  • internal/admin/dashboard/static/css/dashboard.css

Comment on lines +104 to +107
// Virtual models are part of the model-config plane, so the unified store
// refreshes on the model-cache cadence — the same interval the provider model
// list uses. Cross-instance staleness is therefore identical to the model
// cache's; operators tune CACHE_MODEL_REFRESH_INTERVAL for faster propagation.

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the config env var name in the operator guidance comment.

Line 107 says CACHE_MODEL_REFRESH_INTERVAL, but the config field uses env:"CACHE_REFRESH_INTERVAL". This mismatch can mislead runtime tuning.

Suggested patch
-	// cache's; operators tune CACHE_MODEL_REFRESH_INTERVAL for faster propagation.
+	// cache's; operators tune CACHE_REFRESH_INTERVAL for faster propagation.
📝 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
// Virtual models are part of the model-config plane, so the unified store
// refreshes on the model-cache cadence — the same interval the provider model
// list uses. Cross-instance staleness is therefore identical to the model
// cache's; operators tune CACHE_MODEL_REFRESH_INTERVAL for faster propagation.
// Virtual models are part of the model-config plane, so the unified store
// refreshes on the model-cache cadence — the same interval the provider model
// list uses. Cross-instance staleness is therefore identical to the model
// cache's; operators tune CACHE_REFRESH_INTERVAL for faster propagation.
🤖 Prompt for 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.

In `@internal/virtualmodels/factory.go` around lines 104 - 107, The comment in the
virtual models factory section references an incorrect environment variable name
CACHE_MODEL_REFRESH_INTERVAL when the actual config field uses
CACHE_REFRESH_INTERVAL. Update the referenced environment variable name in the
comment that discusses the unified store refresh cadence and operator tuning to
use the correct env var name CACHE_REFRESH_INTERVAL instead of
CACHE_MODEL_REFRESH_INTERVAL to prevent operator confusion during runtime
configuration.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

@SantiagoDePolonia Both fixes in 19e6c09 are exactly right:

  • store_postgresql.gorollbackContext() on the deferred tx.Rollback correctly mirrors the service.go pattern and ensures the rollback isn't blocked by a cancelled seed context.
  • store_test.go — the new mid-batch failure (row 1 written, row 2 non-finite Weight encoding failure) is a genuine test of the atomicity guarantee, unlike the pre-BeginTx cancelled-context case that never reached a commit-or-rollback decision point.

The cleanups in 3b16799 are good housekeeping: the aliases.jsvirtual-models.js rename aligns the filename with the unified model, and removing the dead model-access-state-badge CSS keeps the stylesheet clean.

This PR is in excellent shape — all findings across the four rounds have been fully addressed with clear rationale for the intentional non-changes. Ready to merge. 🐇✨

✅ Action performed

Failed to resolve some comments. Please check permissions and try again.

@SantiagoDePolonia SantiagoDePolonia merged commit 6b29627 into main Jun 23, 2026
20 checks passed
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