§22 S6: per-collection enabled_models — the §22.12 model-universe chain
A collection's .collection.yaml may carry an enabled_models list that NARROWS its project's universe, which narrows the deployment ENABLED_MODELS. Resolution (extending §6.6/§6.7): funder ∩ per-entry models ∩ collection ∩ project, with the operator providers as the ceiling. - migration 031: additive collections.config_json (parallels projects.config_json). - registry.parse_collection_manifest: read enabled_models into CollectionEntry.config (absent = inherit, present incl. [] = narrow; [] opts the collection out of AI); reject a non-list. _upsert_named_collection persists config_json. The default collection (from projects.yaml) leaves it NULL — it inherits the project. - models_resolver: _scope_narrowed_universe narrows the operator universe by the entry's project then collection enabled_models; the funder universe is bounded by it too. A collection cannot widen its project (narrowing from the ceiling). - collections.get_collection surfaces enabled_models; GET /api/projects/:id/collections/:cid returns it. - test_s6_collection_models_vertical.py: 9 cases (parse, the narrowing chain incl. opt-out + cannot-widen, API surfacing). Backend: 525 passed. Per SPEC §22.12. Releases as part of v0.45.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""§22.12 S6 — per-collection model universe.
|
||||
|
||||
A collection's `.collection.yaml` may carry an `enabled_models` list that
|
||||
NARROWS its project's universe, which in turn narrows the deployment
|
||||
ENABLED_MODELS. The resolution chain (extending §6.6/§6.7) is:
|
||||
|
||||
funder ∩ per-entry models ∩ collection universe ∩ project universe
|
||||
(operator providers = the ceiling)
|
||||
|
||||
These tests prove (1) the manifest parser reads `enabled_models`, (2) the
|
||||
mirror stores it on the collection row, (3) the resolver narrows the base
|
||||
universe by project then collection, with absent = inherit and [] = opt-out,
|
||||
and (4) the collection API surfaces the collection's own narrowing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db, models_resolver, registry
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401
|
||||
|
||||
|
||||
def _install_two_providers(app) -> None:
|
||||
app.state.providers.clear()
|
||||
app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B")
|
||||
app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H")
|
||||
|
||||
|
||||
def _set_project_models(project_id: str, models) -> None:
|
||||
cfg = {} if models is None else {"enabled_models": models}
|
||||
db.conn().execute(
|
||||
"UPDATE projects SET config_json = ? WHERE id = ?",
|
||||
(json.dumps(cfg), project_id),
|
||||
)
|
||||
|
||||
|
||||
def _set_collection_models(collection_id: str, models) -> None:
|
||||
cfg = None if models is None else json.dumps({"enabled_models": models})
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = ?",
|
||||
(cfg, collection_id),
|
||||
)
|
||||
|
||||
|
||||
# ── manifest parsing ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_manifest_parses_enabled_models_into_config():
|
||||
ce = registry.parse_collection_manifest(
|
||||
"type: bdd\nname: Features\nenabled_models: [claude, gemini]\n"
|
||||
)
|
||||
assert ce.config.get("enabled_models") == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_manifest_without_enabled_models_has_no_key():
|
||||
ce = registry.parse_collection_manifest("type: document\nname: Model\n")
|
||||
assert "enabled_models" not in ce.config
|
||||
|
||||
|
||||
def test_manifest_rejects_non_list_enabled_models():
|
||||
import pytest
|
||||
with pytest.raises(registry.RegistryError):
|
||||
registry.parse_collection_manifest("type: bdd\nenabled_models: claude\n")
|
||||
|
||||
|
||||
# ── resolver narrowing (the §22.12 chain) ──────────────────────────────────
|
||||
|
||||
|
||||
def test_resolver_inherits_operator_universe_when_no_scope_narrowing(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# No project/collection narrowing → full operator universe.
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_resolver_narrows_by_project_universe(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
_set_project_models("default", ["gemini"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["gemini"]
|
||||
|
||||
|
||||
def test_resolver_collection_narrows_within_project(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# Project allows both; the collection narrows to claude only.
|
||||
_set_project_models("default", ["claude", "gemini"])
|
||||
_set_collection_models("default", ["claude"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["claude"]
|
||||
|
||||
|
||||
def test_resolver_collection_empty_list_opts_out(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
_set_collection_models("default", []) # opt this collection out of AI
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == []
|
||||
|
||||
|
||||
def test_resolver_collection_cannot_widen_project(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# Project restricts to gemini; the collection naming claude+gemini
|
||||
# cannot re-add claude (narrowing only).
|
||||
_set_project_models("default", ["gemini"])
|
||||
_set_collection_models("default", ["claude", "gemini"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["gemini"]
|
||||
|
||||
|
||||
# ── API surfacing ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_collection_api_surfaces_enabled_models(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
_set_collection_models("default", ["claude"])
|
||||
r = client.get("/api/projects/default/collections/default")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("enabled_models") == ["claude"]
|
||||
Reference in New Issue
Block a user