Post-v1: per-RFC model availability (UX half) folded into §6.6

First §19.2 candidate settled after v1. The heavier per-RFC-model
topic subdivided into UX (this) and credential delegation + funder
role (still §19.2). New §6.6 carries the rule: an optional `models:`
frontmatter field on the meta-repo RFC entry; absent inherits the
operator universe, populated narrows the picker to the intersection
with provisioned providers, `[]` opts the RFC out of AI entirely.
The first resolved entry is the RFC default. §18's ENABLED_MODELS is
reframed as the operator universe.

Code: migration 009 adds nullable cached_rfcs.models_json (NULL ≠ []
is load-bearing); entry.py grows the optional field with absent-vs-
empty round-tripping in parse/serialize; new models_resolver module
holds the rule; api_branches replaces /api/models with the slug-aware
/api/rfcs/{slug}/models and threads the chat + reask paths through
the resolver; api_prs §10.2 uses the resolver and extends the stub
fallback to the opt-out case; frontend passes slug to listModels.

Tests 106/106 green (96 prior + 10 in test_per_rfc_models.py). No
behavioral change for entries without `models:` — operator universe
preserved as default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 05:42:15 -07:00
parent 36635049c7
commit a255429e57
11 changed files with 755 additions and 57 deletions
+27 -15
View File
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, models_resolver
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -111,21 +111,23 @@ def make_router(
) -> APIRouter:
router = APIRouter()
default_model = next(iter(providers)) if providers else ""
# -------------------------------------------------------------------
# §17: model picker (the prototype carryover, scoped here since
# Slice 2 is where chat lights up).
# §6.6 / §17: per-RFC model picker. The option list and default are
# resolved per RFC by intersecting the meta-repo entry's optional
# `models:` frontmatter with the operator's provisioned universe.
# An empty resolved list means the RFC has opted out of AI per
# §6.6 and the picker surfaces no options.
# -------------------------------------------------------------------
@router.get("/api/models")
async def list_models() -> dict[str, Any]:
@router.get("/api/rfcs/{slug}/models")
async def list_models_for_rfc(slug: str) -> dict[str, Any]:
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
return {
"models": [
{"id": key, "name": p.display_name}
for key, p in providers.items()
{"id": key, "name": providers[key].display_name}
for key in resolved
],
"default": default_model,
"default": resolved[0] if resolved else "",
}
# -------------------------------------------------------------------
@@ -560,15 +562,21 @@ def make_router(
thread_id = row["thread_id"]
if thread_id is None:
raise HTTPException(409, "Change has no originating thread")
if not providers:
# §6.6: refuse cleanly if the RFC's resolved model list is empty —
# either the operator has no providers, or the RFC opted out
# (`models: []`), or the entry names only models the operator
# no longer provisions. Same shape; same honest refusal.
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
if not resolved:
raise HTTPException(503, "No AI providers configured")
reask_model = resolved[0]
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
provider = next(iter(providers.values()))
provider = providers[reask_model]
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
history = chat_layer.build_history(thread_id)
reask_prompt = (
@@ -581,7 +589,7 @@ def make_router(
thread_id=thread_id, author_user_id=viewer.user_id, text=reask_prompt, quote=None
)
assistant_id = chat_layer.append_assistant_placeholder(
thread_id=thread_id, model_id=default_model
thread_id=thread_id, model_id=reask_model
)
text = provider.send(system, history + [{"role": "user", "content": reask_prompt}])
@@ -892,9 +900,13 @@ def make_router(
thread = _require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
if not providers:
# §6.6: option list and default come from the per-RFC resolved set,
# not the operator universe. body.model is honored only if it sits
# inside the resolved set; otherwise we fall to the RFC default.
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
if not resolved:
raise HTTPException(503, "No AI providers configured")
model_key = body.model if body.model in providers else default_model
model_key = body.model if body.model in resolved else resolved[0]
provider = providers[model_key]
# Fetch the live branch body so the prompt is anchored to
+10 -9
View File
@@ -23,7 +23,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, models_resolver
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -68,13 +68,13 @@ def make_router(
) -> APIRouter:
router = APIRouter()
default_model = next(iter(providers)) if providers else ""
# -------------------------------------------------------------------
# §10.2: AI-drafted PR title and description.
# Returned ahead of submit so the modal renders with prefilled values
# the contributor can edit. The contributor's gesture is what
# produces the open-pr call; the draft is just a starting point.
# Per §6.6 the model used is the RFC's resolved default; an empty
# resolved list falls back to the deterministic stub.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
@@ -90,9 +90,10 @@ def make_router(
if not branch_fetched:
raise HTTPException(404, f"Branch {path} not found")
chat_messages = _branch_chat_excerpt(slug, branch)
rfc_default_model = models_resolver.default_model_for_rfc(slug, providers)
title, description = _draft_with_provider(
providers=providers,
default_model=default_model,
default_model=rfc_default_model,
rfc_title=rfc["title"],
main_body=_extract_body(rfc, (main_fetched or ("", ""))[0]),
branch_body=_extract_body(rfc, branch_fetched[0]),
@@ -795,12 +796,12 @@ def _draft_with_provider(
"""Per §10.2: AI-drafted title (spec voice) and description (24
sentences pulling from chat).
When no provider is configured we fall back to a deterministic
stub — the surface still works; the contributor just edits the
text. The fallback also matches the test seam where Slice 3
integration tests don't always inject a fake provider.
When no provider is configured — or per §6.6 the RFC's resolved
list is empty (operator universe empty, frontmatter opt-out, or
intersection empty) — we fall back to a deterministic stub. The
surface still works; the contributor edits the text.
"""
if not providers:
if not providers or not default_model:
return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
provider = providers.get(default_model) or next(iter(providers.values()))
system = (
+7 -2
View File
@@ -76,13 +76,16 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
# §6.6: models_json stays NULL when the frontmatter key is absent
# (inherit operator universe) and '[]' for the explicit opt-out.
models_json = json.dumps(entry.models) if entry.models is not None else None
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
models_json, body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
@@ -95,6 +98,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
owners_json = excluded.owners_json,
arbiters_json = excluded.arbiters_json,
tags_json = excluded.tags_json,
models_json = excluded.models_json,
body = excluded.body,
body_sha = excluded.body_sha,
last_entry_commit_at = datetime('now'),
@@ -113,6 +117,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
json.dumps(entry.owners),
json.dumps(entry.arbiters),
json.dumps(entry.tags),
models_json,
entry.body,
body_sha,
),
+17
View File
@@ -23,6 +23,8 @@ FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_ABSENT = object()
@dataclass
class Entry:
@@ -38,6 +40,11 @@ class Entry:
owners: list[str] = field(default_factory=list)
arbiters: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
# §6.6: per-RFC model availability. None means the key is absent
# from frontmatter (inherit the operator universe). An empty list
# means an explicit opt-out from AI on this RFC. A populated list
# narrows the picker to its intersection with the operator universe.
models: list[str] | None = None
body: str = ""
@@ -47,6 +54,11 @@ def parse(text: str) -> Entry:
raise ValueError("Entry file missing frontmatter")
fm = yaml.safe_load(match.group(1)) or {}
body = match.group(2).lstrip("\n")
raw_models = fm.get("models", _ABSENT)
if raw_models is _ABSENT or raw_models is None:
models: list[str] | None = None
else:
models = [str(m) for m in raw_models]
return Entry(
slug=str(fm.get("slug") or ""),
title=str(fm.get("title") or ""),
@@ -60,6 +72,7 @@ def parse(text: str) -> Entry:
owners=list(fm.get("owners") or []),
arbiters=list(fm.get("arbiters") or []),
tags=list(fm.get("tags") or []),
models=models,
body=body,
)
@@ -80,6 +93,10 @@ def serialize(entry: Entry) -> str:
"arbiters": entry.arbiters,
"tags": entry.tags,
}
# §6.6: emit `models:` only when set. Absent in the frontmatter
# is meaningfully different from `models: []` per §6.6.
if entry.models is not None:
fm["models"] = entry.models
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
body = entry.body.lstrip("\n")
if body:
+68
View File
@@ -0,0 +1,68 @@
"""§6.6 per-RFC model availability — the resolver.
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:
- Cache row's `models_json` is NULL → the field is absent on the entry.
Resolved list = the operator's provisioned 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 operator's
provisioned universe, preserving the entry's stated order.
The empty case folds in naturally: an entry with `models: []` yields an
empty intersection, and an entry whose listed models are no longer
provisioned by the operator also yields an empty intersection. Callers
treat both the same surfaces 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
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.
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.
"""
universe = 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 universe
try:
listed = [str(m) for m in json.loads(row["models_json"])]
except (json.JSONDecodeError, TypeError):
return universe
if not listed:
return []
return [m for m in listed if m in providers]
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 ""