§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:
@@ -8,11 +8,26 @@ authz (auth.py) recovers a row's project by joining `collections` on
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from . import db
|
||||
|
||||
DEFAULT_COLLECTION_ID = "default"
|
||||
|
||||
|
||||
def _enabled_models_from_config(config_json: str | None) -> list[str] | None:
|
||||
"""§22.12 per-collection enabled_models from a `config_json` blob, or None
|
||||
when unset (the collection inherits its project's universe)."""
|
||||
if not config_json:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(config_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
|
||||
return [str(m) for m in em] if isinstance(em, list) else None
|
||||
|
||||
|
||||
def default_collection_id(project_id: str) -> str:
|
||||
"""The id of a project's default (S1: sole) collection. Falls back to the
|
||||
literal 'default' when the project has no collection row yet."""
|
||||
@@ -60,13 +75,18 @@ def subfolder_of(collection_id: str) -> str:
|
||||
|
||||
|
||||
def get_collection(collection_id: str) -> dict | None:
|
||||
"""The full collection row as a dict, or None if unknown."""
|
||||
"""The full collection row as a dict, or None if unknown. `enabled_models`
|
||||
(§22.12) is unpacked from `config_json` as a list, or None when unset."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
|
||||
"FROM collections WHERE id = ?",
|
||||
"SELECT id, project_id, type, subfolder, initial_state, visibility, name, "
|
||||
"config_json FROM collections WHERE id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None))
|
||||
return out
|
||||
|
||||
|
||||
def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]:
|
||||
|
||||
@@ -38,22 +38,78 @@ from . import db, funder
|
||||
from .providers import BaseProvider
|
||||
|
||||
|
||||
def _models_from_config(config_json: str | None) -> list[str] | None:
|
||||
"""The `enabled_models` list inside a project/collection `config_json`,
|
||||
or None when the key is absent (meaning "no narrowing at this tier")."""
|
||||
if not config_json:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(config_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
|
||||
return [str(m) for m in em] if isinstance(em, list) else None
|
||||
|
||||
|
||||
def _narrow(universe: list[str], allowed: list[str] | None) -> list[str]:
|
||||
"""Intersect `universe` with `allowed`, preserving universe order. `allowed`
|
||||
None means no narrowing at this tier; an empty list narrows to empty (an
|
||||
opt-out), exactly like the §6.6 per-entry `models: []`."""
|
||||
if allowed is None:
|
||||
return universe
|
||||
allow = set(allowed)
|
||||
return [k for k in universe if k in allow]
|
||||
|
||||
|
||||
def _scope_narrowed_universe(
|
||||
collection_id: str | None, operator_keys: list[str]
|
||||
) -> list[str]:
|
||||
"""§22.12 — narrow the operator (deployment) universe by the entry's
|
||||
project then its collection `enabled_models`. Each tier may only narrow;
|
||||
a missing config at a tier is a no-op. The collection cannot widen its
|
||||
project because narrowing composes from the operator ceiling downward."""
|
||||
if collection_id is None:
|
||||
return list(operator_keys)
|
||||
conn = db.conn()
|
||||
crow = conn.execute(
|
||||
"SELECT project_id, config_json FROM collections WHERE id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
universe = list(operator_keys)
|
||||
if crow is None:
|
||||
return universe
|
||||
prow = conn.execute(
|
||||
"SELECT config_json FROM projects WHERE id = ?", (crow["project_id"],)
|
||||
).fetchone()
|
||||
universe = _narrow(universe, _models_from_config(prow["config_json"] if prow else None))
|
||||
universe = _narrow(universe, _models_from_config(crow["config_json"]))
|
||||
return universe
|
||||
|
||||
|
||||
def resolve_models_for_rfc(
|
||||
slug: str, providers: dict[str, BaseProvider]
|
||||
) -> list[str]:
|
||||
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7.
|
||||
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7 and
|
||||
§22.12.
|
||||
|
||||
The first entry is the RFC's default model. An empty list means
|
||||
AI is unavailable on this RFC and callers refuse the AI surface.
|
||||
"""
|
||||
# §6.7: the funder universe (if any) replaces the operator universe
|
||||
# as the base set the §6.6 frontmatter intersects against.
|
||||
funder_universe = funder.resolve_funder_universe(slug, providers)
|
||||
base_universe = funder_universe if funder_universe is not None else list(providers.keys())
|
||||
row = db.conn().execute(
|
||||
"SELECT models_json FROM cached_rfcs WHERE slug = ?",
|
||||
"SELECT collection_id, models_json FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
collection_id = row["collection_id"] if row is not None else None
|
||||
# §22.12: first narrow the operator universe by the entry's project +
|
||||
# collection enabled_models (the deployment → project → collection chain).
|
||||
scope_universe = _scope_narrowed_universe(collection_id, list(providers.keys()))
|
||||
# §6.7: a consenting funder universe (if any) replaces the operator universe
|
||||
# as the base set — still bounded by the §22.12 scope narrowing above.
|
||||
funder_universe = funder.resolve_funder_universe(slug, providers)
|
||||
if funder_universe is not None:
|
||||
base_universe = _narrow(list(funder_universe), scope_universe)
|
||||
else:
|
||||
base_universe = scope_universe
|
||||
if row is None or row["models_json"] is None:
|
||||
return list(base_universe)
|
||||
try:
|
||||
|
||||
+16
-4
@@ -64,6 +64,7 @@ class CollectionEntry:
|
||||
visibility: str | None
|
||||
initial_state: str
|
||||
name: str | None
|
||||
config: dict = field(default_factory=dict) # §22.12 enabled_models
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -149,7 +150,16 @@ def parse_collection_manifest(text: str) -> CollectionEntry:
|
||||
raise RegistryError(f"collection has invalid initial_state {initial_state!r}")
|
||||
name = raw.get("name")
|
||||
name = str(name).strip() if name else None
|
||||
return CollectionEntry(ctype, vis, initial_state, name)
|
||||
# §22.12: an optional per-collection enabled_models list that narrows the
|
||||
# project's universe. Absent → no narrowing (inherit). Present (incl. empty)
|
||||
# → narrowing applies; [] opts the collection out of AI.
|
||||
cfg: dict = {}
|
||||
if raw.get("enabled_models") is not None:
|
||||
em = raw["enabled_models"]
|
||||
if not isinstance(em, list):
|
||||
raise RegistryError("collection enabled_models must be a list")
|
||||
cfg["enabled_models"] = [str(m) for m in em]
|
||||
return CollectionEntry(ctype, vis, initial_state, name, cfg)
|
||||
|
||||
|
||||
def _default_collection_id(project_id: str, default_id: str) -> str:
|
||||
@@ -268,17 +278,19 @@ def _upsert_named_collection(
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collections
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
project_id = excluded.project_id,
|
||||
initial_state = excluded.initial_state,
|
||||
visibility = excluded.visibility,
|
||||
name = excluded.name,
|
||||
config_json = excluded.config_json,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, sha),
|
||||
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name,
|
||||
json.dumps(ce.config), sha),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- §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 narrows the deployment ENABLED_MODELS).
|
||||
-- Mirrored into a `config_json` blob on the collection row, paralleling
|
||||
-- `projects.config_json` (which already holds the project's enabled_models +
|
||||
-- theme). Additive only — no rebuild. NULL means "no per-collection narrowing;
|
||||
-- inherit the project's universe."
|
||||
ALTER TABLE collections ADD COLUMN config_json TEXT;
|
||||
@@ -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