Files
rfc-app/backend/app/models_resolver.py
T
Ben Stull 79a27a946b §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>
2026-06-06 01:30:16 -07:00

136 lines
5.5 KiB
Python

"""§6.6 per-RFC model availability — the resolver, extended in §6.7
with the funder-universe layer.
The meta-repo entry's optional `models:` frontmatter and the operator's
provisioned providers (the §18 `ENABLED_MODELS` universe) combine into a
single resolved list per RFC. Every AI surface picks against that list:
the §8.12 chat picker, the §10.2 PR-draft, the §8.13 flag-resolution
invocation, the §9.1 tag suggestions when graduation is in scope.
Rule, in one place:
- The base universe is normally the operator's provisioned providers.
When a consenting funder is in effect for the RFC per §6.7, the base
universe is replaced (not augmented) by the funder's registered
universe — the subset of operator-enabled picker keys the funder has
supplied credentials for.
- Cache row's `models_json` is NULL → the field is absent on the entry.
Resolved list = the base universe.
- Cache row's `models_json` is a JSON array → the field is set on the
entry. Resolved list = intersection of the array with the base
universe, preserving the entry's stated order.
The empty case folds in naturally: `models: []` yields empty, an entry
whose listed models aren't in the operator's enabled set yields empty,
a consenting funder whose registrations don't intersect the operator's
enabled set yields empty. Callers treat all four the same — refuse
cleanly per §6.6.
The function is slug-aware and provider-aware; it does not depend on
the FastAPI request lifecycle, which keeps it cheap to call inside any
endpoint that knows the slug and has the providers dict in scope.
"""
from __future__ import annotations
import json
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 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.
"""
row = db.conn().execute(
"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:
listed = [str(m) for m in json.loads(row["models_json"])]
except (json.JSONDecodeError, TypeError):
return list(base_universe)
if not listed:
return []
base_set = set(base_universe)
return [m for m in listed if m in base_set]
def default_model_for_rfc(
slug: str, providers: dict[str, BaseProvider]
) -> str:
"""The first entry in the resolved list, or empty string if none.
Callers that need a deterministic single choice — §10.2's draft,
§9.1's tag suggestions — use this. An empty return signals the
refusal path per §6.6.
"""
resolved = resolve_models_for_rfc(slug, providers)
return resolved[0] if resolved else ""