"""§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 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. 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 = ?", (slug,), ).fetchone() 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 ""