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:
+27
-15
@@ -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
@@ -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 (2–4
|
||||
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 = (
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ""
|
||||
Reference in New Issue
Block a user