"""§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 ""