79a27a946b
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>
136 lines
5.5 KiB
Python
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 ""
|