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
+122 -28
View File
@@ -77,6 +77,9 @@ graduated_by: null
owners: [] # contributors elevated for this RFC owners: [] # contributors elevated for this RFC
arbiters: [ben] # contributors with merge authority for this RFC arbiters: [ben] # contributors with merge authority for this RFC
tags: [identity, schema] tags: [identity, schema]
# models: # optional per-§6.6 — absent inherits the
# - claude # operator universe; [] opts the RFC out of AI
# - gemini # entirely; populated narrows the picker
--- ---
## Why this RFC is needed ## Why this RFC is needed
@@ -395,6 +398,67 @@ the acting user. Gitea's commit log is for code archaeology; the
`actions` and `permission_events` tables are the real accountability `actions` and `permission_events` tables are the real accountability
record. record.
### 6.6 Per-RFC model availability
Which AI models contributors can pick from is configurable per RFC.
The configuration lives in the meta-repo entry's frontmatter as an
optional `models:` list of model identifiers (see §2.1), in the same
shape as `owners:` and `arbiters:` — frontmatter-native, edited via
the meta-repo PR flow that already governs the rest of the entry's
canonical state, mirrored into the §4 cache for read-without-roundtrip.
The field is structurally
*optional* and the absent/empty distinction is load-bearing:
- **Absent** (`models:` key omitted) — the RFC inherits the operator's
universe. The operator's universe is the set of models the
deployment is provisioned to run, configured at the process level
per §18. A freshly proposed super-draft and any RFC whose
contributors haven't expressed a preference fall here.
- **Present and non-empty** — the RFC opts into a specific subset.
The picker's option list, at every AI surface scoped to this RFC,
is the intersection of this list with the operator's currently
provisioned models. Models the RFC names but the operator no
longer provisions are silently hidden from the picker; the
frontmatter list is preserved so a later operator change can
restore them.
- **Present and empty** (`models: []`) — the RFC opts out of AI
participation entirely. Every AI surface on the RFC honors the
refusal honestly: the chat composer's AI affordances are absent,
flag-resolution's "Ask Claude to propose a fix" is absent, the
§10.2 PR-draft falls back to its deterministic stub, the §9.1
AI-suggested tags surface is absent. A contributor who tries to
invoke the AI sees the refusal in the surface, not a mid-turn
failure.
The **RFC default model** is the first entry in the resolved list
(intersection with the operator's provisioned set; or the operator
universe when the field is absent). Where a deterministic single
model must be chosen and the contributor has not picked one — the
§10.2 PR-draft, the §9.1 tag suggestions, the §8.13 "Ask Claude to
propose a fix" invocation from an empty thread — that's the model
used. Per-message picker grain inside a chat thread (the §18
prototype carryover) is preserved: each message can name a different
model from the resolved list, and the picker's currently-selected
entry persists across messages within a session.
Editing the field — adding a model, removing one, switching to the
empty-list opt-out — follows the same authority rules as editing
`owners:` or `arbiters:`. Super-draft entries: the claimed owners
and app-wide admins. Active RFCs: the RFC's owners and arbiters per
§6.3, plus app-wide admins. The dedicated chrome for the edit (the
metadata pane equivalent that the §19.2 metadata-pane UX topic will
settle for super-drafts, and whatever surface admins use for active-
RFC frontmatter edits) is a downstream concern — the structural
commitment here is the field, the resolution rule, and the
configuration capability, not the click path.
This section names *which* models are permitted on a given RFC. It
does not name *whose API resources pay for them* — credentials
remain operator-supplied per §18. Credential delegation and the
funder role are a separate topic (§19.2) whose settlement will
attach a parallel "credential set" notion alongside this one
without changing the availability surface.
--- ---
## 7. The left pane ## 7. The left pane
@@ -783,6 +847,15 @@ the scroll-to-editor binding. Resolved and stale threads stay in the
data; the chat feed's filter affordances surface or hide them on data; the chat feed's filter affordances surface or hide them on
demand. demand.
The chat's model picker (the §18 prototype carryover) draws its
option list from the resolved per-RFC set per §6.6 — the
intersection of the entry's optional `models:` frontmatter (or the
operator universe when absent) with the operator's currently
provisioned providers. An RFC whose resolved list is empty surfaces
the AI affordances as absent rather than disabled-but-present, so
the refusal reads as a property of the RFC, not as a transient
error.
### 8.13 Flags ### 8.13 Flags
A flag is the lightweight "I'm pointing at this, it's a problem" A flag is the lightweight "I'm pointing at this, it's a problem"
@@ -1290,8 +1363,13 @@ branch chat, both editable inline before submit:
to consider. to consider.
The model is told the audience is *an arbiter*, not Ben specifically — The model is told the audience is *an arbiter*, not Ben specifically —
the framework has to scale past one person. Title and description remain the framework has to scale past one person. The model used for the
editable post-open by the contributor or any of the RFC's arbiters. draft is the RFC's default per §6.6 — the first entry in the
resolved per-RFC list. When that list is empty (the RFC opts out of
AI per §6.6), the draft falls back to a deterministic stub naming
the RFC title; the contributor edits the prefilled text as usual.
Title and description remain editable post-open by the contributor
or any of the RFC's arbiters.
There is no reviewer picker. The RFC's arbiters (§6.3) are the implicit There is no reviewer picker. The RFC's arbiters (§6.3) are the implicit
reviewer set; surfacing a per-PR picker would either duplicate that or reviewer set; surfacing a per-PR picker would either duplicate that or
@@ -2202,7 +2280,9 @@ specified* and what is intentionally out of scope for v1.
rich markdown, headings, links, code blocks. rich markdown, headings, links, code blocks.
- **The per-RFC and per-branch chat UX.** Threading model, AI - **The per-RFC and per-branch chat UX.** Threading model, AI
participation, the discuss-vs-contribute mode distinction from the participation, the discuss-vs-contribute mode distinction from the
prototype, the selection tooltip, the prompt bar, model picker. prototype, the selection tooltip, the prompt bar, the model picker
chrome (its option-list source is settled in §6.6 / §8.12; the
visual treatment and per-thread persistence remain).
- **The revision flow.** How proposed changes from AI or contributors - **The revision flow.** How proposed changes from AI or contributors
appear, the change-card panel, accept/decline/edit, tracked appear, the change-card panel, accept/decline/edit, tracked
insertions/deletions in the editor. insertions/deletions in the editor.
@@ -2434,7 +2514,11 @@ them:
- The structured `<change>` / `<original>` / `<proposed>` / `<reason>` - The structured `<change>` / `<original>` / `<proposed>` / `<reason>`
protocol for AI-proposed edits. protocol for AI-proposed edits.
- Multi-provider LLM support: Anthropic Claude, Google Gemini, OpenAI / - Multi-provider LLM support: Anthropic Claude, Google Gemini, OpenAI /
GitHub Copilot. `ENABLED_MODELS` and per-provider API keys via env. GitHub Copilot. `ENABLED_MODELS` and per-provider API keys via env
these now define the operator's *universe* of available models, the
set the deployment is provisioned to run. Per-RFC selection from
that universe is settled in §6.6; credential delegation and the
funder role remain a §19.2 candidate.
- The discuss-vs-contribute distinction inside an RFC view (to be - The discuss-vs-contribute distinction inside an RFC view (to be
fully specified in the follow-up session). fully specified in the follow-up session).
- Gitea OAuth for user authentication. The OAuth identity is the basis - Gitea OAuth for user authentication. The OAuth identity is the basis
@@ -2604,24 +2688,34 @@ build surfaces evidence for which one matters next. Topics are
listed roughly in order of expected weight; the order is not listed roughly in order of expected weight; the order is not
binding. binding.
- **Per-RFC model availability and credential delegation.** Which - **Per-RFC model availability — UX half.** *Settled in the
AI models contributors can pick from when chatting on a given post-v1 session that picked it. The meta-repo entry frontmatter
RFC, and who supplies the API resources for those models. now carries an optional `models:` list per §6.6; the resolution
Replaces §18's app-level `ENABLED_MODELS` and env-supplied keys rule (absent inherits the operator universe, populated narrows
with per-RFC-scoped configuration. Touches every AI surface — the picker by intersection, `[]` opts the RFC out of AI
every chat thread (§8.12), every change-proposal turn (§8.9, entirely) is uniform across every AI surface — §8.12 chat
§8.11), every flag-resolution invocation (§8.13), the AI-drafted picker, §8.9 / §8.11 change-proposal turns, §8.13 flag
PR title and description (§10.2), and the propose modal's resolution, §10.2 PR draft, §9.1 tag suggestions. The §18
AI-suggested tags (§9.1). Touches §5 (schema for model config `ENABLED_MODELS` env contract is reframed as the operator's
and credentials), §6 (possibly a `funder` role, or a per-RFC universe of provisioned models; the per-RFC list picks from
capability extension along the lines of §6.2's per-user within it. The dedicated chrome for editing the field is
overrides), and §18 (carryover supersession). Subdividable into downstream — clustered with the metadata-pane UX candidate
"model availability" (lighter, UX-shaped) and "credential below for super-drafts, and with whatever surface admins use
delegation and the funder role" (heavier — security, billing, for active-RFC frontmatter edits.*
abuse mitigation, rotation, mid-conversation key failure) if the - **Per-RFC credential delegation and the funder role.** The
session driver judges the combined scope too large. Load-bearing heavier half of the original per-RFC-model topic — who supplies
once the framework runs past single-operator deployment; the API resources behind the models a given RFC permits. The
defer-able until then. availability half (§6.6) names *which* models are allowed;
credential delegation names *whose keys pay for the calls*.
Touches security (delegation grant shape, revocation), billing
(per-RFC spend, attribution), abuse mitigation (rate limits per
funder, quota exhaustion), key rotation (mid-conversation
failure handling, retry-with-fallback), and a possible
`funder` role distinct from owner/arbiter that scopes credential
authority without conferring frontmatter authority. Load-bearing
once the framework runs past single-operator deployment; the v1
shape — operator-supplied keys per §18 — is the right default
until a real multi-operator scenario surfaces.
- **Admin surfaces.** Where role management, muting, audit-log - **Admin surfaces.** Where role management, muting, audit-log
views, the graduation-readiness queue, and Topic 13's views, the graduation-readiness queue, and Topic 13's
notification-preferences settings (email categories per §15.4, notification-preferences settings (email categories per §15.4,
@@ -2763,12 +2857,12 @@ binding.
- **The §10.2 modal's AI-drafted text when no provider is - **The §10.2 modal's AI-drafted text when no provider is
configured.** Slice 3 falls back to a deterministic stub configured.** Slice 3 falls back to a deterministic stub
(`Edits to <RFC title>` plus a character-count line) when the (`Edits to <RFC title>` plus a character-count line) when the
app has no LLM provider. The fallback is functional but does app has no LLM provider. The §6.6 settlement extends the same
not produce spec-voice text. Per-RFC model availability (the fallback to the case where the RFC's resolved model list is
first §19.2 candidate, on the funder-role topic) will need to empty — the RFC has opted out of AI entirely. The fallback is
settle the credential-delegation shape before this earns its functional but does not produce spec-voice text; improving the
own topic; until then, the stub is the right shape for the no-credential-available render remains its own future topic,
no-credential-available case. defer-able until evidence shows the stub bites.
- **§10.9 replay AI participation.** Slice 3 implements the - **§10.9 replay AI participation.** Slice 3 implements the
structural §10.9 path — fresh resolution branch off main, replay structural §10.9 path — fresh resolution branch off main, replay
the accepted changes whose `original` text still locates exactly the accepted changes whose `original` text still locates exactly
+27 -15
View File
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field 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 .bot import Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError from .gitea import Gitea, GiteaError
@@ -111,21 +111,23 @@ def make_router(
) -> APIRouter: ) -> APIRouter:
router = APIRouter() router = APIRouter()
default_model = next(iter(providers)) if providers else ""
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §17: model picker (the prototype carryover, scoped here since # §6.6 / §17: per-RFC model picker. The option list and default are
# Slice 2 is where chat lights up). # 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") @router.get("/api/rfcs/{slug}/models")
async def list_models() -> dict[str, Any]: async def list_models_for_rfc(slug: str) -> dict[str, Any]:
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
return { return {
"models": [ "models": [
{"id": key, "name": p.display_name} {"id": key, "name": providers[key].display_name}
for key, p in providers.items() 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"] thread_id = row["thread_id"]
if thread_id is None: if thread_id is None:
raise HTTPException(409, "Change has no originating thread") 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") raise HTTPException(503, "No AI providers configured")
reask_model = resolved[0]
owner, repo = _repo_for(rfc, branch) owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch) path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch) fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0], branch) if fetched else "" 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) system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
history = chat_layer.build_history(thread_id) history = chat_layer.build_history(thread_id)
reask_prompt = ( reask_prompt = (
@@ -581,7 +589,7 @@ def make_router(
thread_id=thread_id, author_user_id=viewer.user_id, text=reask_prompt, quote=None thread_id=thread_id, author_user_id=viewer.user_id, text=reask_prompt, quote=None
) )
assistant_id = chat_layer.append_assistant_placeholder( 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}]) text = provider.send(system, history + [{"role": "user", "content": reask_prompt}])
@@ -892,9 +900,13 @@ def make_router(
thread = _require_thread(slug, branch, thread_id) thread = _require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer): if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private") 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") 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] provider = providers[model_key]
# Fetch the live branch body so the prompt is anchored to # 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 fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field 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 .bot import Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError from .gitea import Gitea, GiteaError
@@ -68,13 +68,13 @@ def make_router(
) -> APIRouter: ) -> APIRouter:
router = APIRouter() router = APIRouter()
default_model = next(iter(providers)) if providers else ""
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §10.2: AI-drafted PR title and description. # §10.2: AI-drafted PR title and description.
# Returned ahead of submit so the modal renders with prefilled values # Returned ahead of submit so the modal renders with prefilled values
# the contributor can edit. The contributor's gesture is what # the contributor can edit. The contributor's gesture is what
# produces the open-pr call; the draft is just a starting point. # 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") @router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
@@ -90,9 +90,10 @@ def make_router(
if not branch_fetched: if not branch_fetched:
raise HTTPException(404, f"Branch {path} not found") raise HTTPException(404, f"Branch {path} not found")
chat_messages = _branch_chat_excerpt(slug, branch) chat_messages = _branch_chat_excerpt(slug, branch)
rfc_default_model = models_resolver.default_model_for_rfc(slug, providers)
title, description = _draft_with_provider( title, description = _draft_with_provider(
providers=providers, providers=providers,
default_model=default_model, default_model=rfc_default_model,
rfc_title=rfc["title"], rfc_title=rfc["title"],
main_body=_extract_body(rfc, (main_fetched or ("", ""))[0]), main_body=_extract_body(rfc, (main_fetched or ("", ""))[0]),
branch_body=_extract_body(rfc, branch_fetched[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 """Per §10.2: AI-drafted title (spec voice) and description (24
sentences pulling from chat). sentences pulling from chat).
When no provider is configured we fall back to a deterministic When no provider is configured — or per §6.6 the RFC's resolved
stub — the surface still works; the contributor just edits the list is empty (operator universe empty, frontmatter opt-out, or
text. The fallback also matches the test seam where Slice 3 intersection empty) — we fall back to a deterministic stub. The
integration tests don't always inject a fake provider. 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) 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())) provider = providers.get(default_model) or next(iter(providers.values()))
system = ( 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: 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( db.conn().execute(
""" """
INSERT INTO cached_rfcs INSERT INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at, (slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json, graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_entry_commit_at, updated_at) models_json, body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET ON CONFLICT(slug) DO UPDATE SET
title = excluded.title, title = excluded.title,
state = excluded.state, state = excluded.state,
@@ -95,6 +98,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
owners_json = excluded.owners_json, owners_json = excluded.owners_json,
arbiters_json = excluded.arbiters_json, arbiters_json = excluded.arbiters_json,
tags_json = excluded.tags_json, tags_json = excluded.tags_json,
models_json = excluded.models_json,
body = excluded.body, body = excluded.body,
body_sha = excluded.body_sha, body_sha = excluded.body_sha,
last_entry_commit_at = datetime('now'), 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.owners),
json.dumps(entry.arbiters), json.dumps(entry.arbiters),
json.dumps(entry.tags), json.dumps(entry.tags),
models_json,
entry.body, entry.body,
body_sha, 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])?$") SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_ABSENT = object()
@dataclass @dataclass
class Entry: class Entry:
@@ -38,6 +40,11 @@ class Entry:
owners: list[str] = field(default_factory=list) owners: list[str] = field(default_factory=list)
arbiters: list[str] = field(default_factory=list) arbiters: list[str] = field(default_factory=list)
tags: 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 = "" body: str = ""
@@ -47,6 +54,11 @@ def parse(text: str) -> Entry:
raise ValueError("Entry file missing frontmatter") raise ValueError("Entry file missing frontmatter")
fm = yaml.safe_load(match.group(1)) or {} fm = yaml.safe_load(match.group(1)) or {}
body = match.group(2).lstrip("\n") 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( return Entry(
slug=str(fm.get("slug") or ""), slug=str(fm.get("slug") or ""),
title=str(fm.get("title") or ""), title=str(fm.get("title") or ""),
@@ -60,6 +72,7 @@ def parse(text: str) -> Entry:
owners=list(fm.get("owners") or []), owners=list(fm.get("owners") or []),
arbiters=list(fm.get("arbiters") or []), arbiters=list(fm.get("arbiters") or []),
tags=list(fm.get("tags") or []), tags=list(fm.get("tags") or []),
models=models,
body=body, body=body,
) )
@@ -80,6 +93,10 @@ def serialize(entry: Entry) -> str:
"arbiters": entry.arbiters, "arbiters": entry.arbiters,
"tags": entry.tags, "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() yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
body = entry.body.lstrip("\n") body = entry.body.lstrip("\n")
if body: 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 ""
@@ -0,0 +1,7 @@
-- §6.6: per-RFC model availability. The frontmatter field is
-- optional; NULL in this column means absent (inherit the operator
-- universe), '[]' means explicit opt-out, '[...]' is the populated
-- list. The distinction is load-bearing — we cannot collapse to a
-- NOT NULL DEFAULT '[]' the way the other *_json columns do.
ALTER TABLE cached_rfcs ADD COLUMN models_json TEXT;
+408
View File
@@ -0,0 +1,408 @@
"""Integration coverage for the §19.2 "per-RFC model availability — UX
half" candidate folded into §6.6.
The meta-repo entry's optional `models:` frontmatter narrows what AI
models contributors can pick from on a given RFC. The resolution rule
is uniform across every AI surface:
- Absent (`models:` key omitted) inherit the operator universe.
- Populated list intersection with the operator's provisioned
providers, preserving the entry's order. First entry is the RFC
default.
- Empty list (`models: []`) AI surfaces refuse honestly.
The tests below prove each branch through the API surface and verify
the cache round-trip from meta-repo frontmatter to picker option list.
"""
from __future__ import annotations
import asyncio
import json
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401
from test_super_draft_vertical import seed_super_draft # noqa: F401
SEED_BODY = (
"Open Human Model is a framework for representing humans.\n\n"
"It defines consent, trait, and agency in compatible terms."
)
def _set_models_json(slug: str, value: str | None) -> None:
"""Write the resolved cached_rfcs.models_json directly. Mirrors what
`_upsert_cached_rfc` would do on the next reconciler sweep, without
requiring a full meta-repo refresh in tests focused on the resolver."""
from app import db
db.conn().execute(
"UPDATE cached_rfcs SET models_json = ? WHERE slug = ?",
(value, slug),
)
def _install_two_providers(app) -> None:
"""Swap two fake providers — `claude` and `gemini` — into the
shared providers dict. The dict is held by reference inside the
router closures (see Slice 2's pattern in test_rfc_view_vertical),
so the mutation propagates."""
app.state.providers.clear()
app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B")
app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H")
# ---------------------------------------------------------------------------
# /api/rfcs/<slug>/models — the picker option-list surface
# ---------------------------------------------------------------------------
def test_absent_frontmatter_inherits_operator_universe(app_with_fake_gitea):
"""When the meta-repo entry has no `models:` field, the picker's
option list is the operator's full provisioned universe."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
# models_json is NULL — set_models_json not called.
r = client.get("/api/rfcs/ohm/models")
assert r.status_code == 200, r.text
body = r.json()
ids = [m["id"] for m in body["models"]]
assert ids == ["claude", "gemini"]
assert body["default"] == "claude"
def test_populated_frontmatter_narrows_picker_to_intersection(app_with_fake_gitea):
"""A populated `models:` list narrows the picker. Operator order
does not matter the entry's stated order is preserved."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
_set_models_json("ohm", json.dumps(["gemini"]))
r = client.get("/api/rfcs/ohm/models")
body = r.json()
assert [m["id"] for m in body["models"]] == ["gemini"]
assert body["default"] == "gemini"
def test_empty_frontmatter_disables_ai_surfaces(app_with_fake_gitea):
"""`models: []` — the RFC opts out of AI per §6.6. The picker
surface returns an empty list and an empty default."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
_set_models_json("ohm", json.dumps([]))
r = client.get("/api/rfcs/ohm/models")
body = r.json()
assert body["models"] == []
assert body["default"] == ""
def test_intersection_with_provisioned_providers(app_with_fake_gitea):
"""An entry that lists models the operator no longer provisions
they're silently hidden from the picker. The frontmatter list
is preserved so a later operator change can restore them; the
cache row is untouched by this test."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
# Frontmatter names a model the operator doesn't have plus one
# it does — only the intersection comes out.
_set_models_json("ohm", json.dumps(["llama-3", "claude"]))
r = client.get("/api/rfcs/ohm/models")
body = r.json()
assert [m["id"] for m in body["models"]] == ["claude"]
assert body["default"] == "claude"
def test_intersection_empty_same_as_opt_out(app_with_fake_gitea):
"""When the frontmatter names only models the operator doesn't
provision, the resolved list is empty and the surface refuses
cleanly same shape as the explicit `models: []` opt-out."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
_set_models_json("ohm", json.dumps(["llama-3", "gpt-5"]))
r = client.get("/api/rfcs/ohm/models")
assert r.json()["models"] == []
# ---------------------------------------------------------------------------
# §10.2 PR draft: model selection follows the RFC default
# ---------------------------------------------------------------------------
def test_pr_draft_uses_rfc_default_model(app_with_fake_gitea):
"""The §10.2 draft picks the RFC's default — the first entry of
the resolved list. We rig the providers so `claude` returns one
title and `gemini` returns a different one, then prove the entry's
pinned model is the one we get back."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Two fakes, distinct outputs.
app.state.providers.clear()
app.state.providers["claude"] = FakeProvider(
"TITLE: from claude\nDESCRIPTION: claude wrote this."
)
app.state.providers["gemini"] = FakeProvider(
"TITLE: from gemini\nDESCRIPTION: gemini wrote this."
)
_set_models_json("ohm", json.dumps(["gemini"]))
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
# Make the branch differ from main so the draft endpoint passes
# its "commits ahead" precondition.
from app import db
from test_rfc_view_vertical import FakeGitea # noqa: F401
# Promote-to-branch starts a branch off main with main's content;
# the simplest way to give it a commit-ahead is via a manual edit
# flush. But for this test, an inline commit on the fake is
# cleaner.
repo_full = "wiggleverse/rfc-0001-ohm"
owner, repo = repo_full.split("/", 1)
new_body = SEED_BODY + "\n\nA further sentence."
fake.files[(owner, repo, branch, "RFC.md")] = {
"content": new_body, "sha": fake._next_sha(),
}
fake.branches[(owner, repo)][branch]["sha"] = fake.files[
(owner, repo, branch, "RFC.md")
]["sha"]
db.conn().execute(
"""
UPDATE cached_branches SET head_sha = ?
WHERE rfc_slug = 'ohm' AND branch_name = ?
""",
(fake.branches[(owner, repo)][branch]["sha"], branch),
)
r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft")
assert r.status_code == 200, r.text
# `gemini` is the resolved default — its title comes back.
assert r.json()["title"] == "from gemini"
def test_pr_draft_falls_back_to_stub_on_opt_out(app_with_fake_gitea):
"""When the RFC opts out of AI (`models: []`), §10.2 falls back to
its deterministic stub the title is the stub format, not an
LLM-generated string."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_install_two_providers(app)
_set_models_json("ohm", json.dumps([]))
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
from app import db
repo_full = "wiggleverse/rfc-0001-ohm"
owner, repo = repo_full.split("/", 1)
new_body = SEED_BODY + "\n\nA further sentence."
fake.files[(owner, repo, branch, "RFC.md")] = {
"content": new_body, "sha": fake._next_sha(),
}
fake.branches[(owner, repo)][branch]["sha"] = fake.files[
(owner, repo, branch, "RFC.md")
]["sha"]
db.conn().execute(
"""
UPDATE cached_branches SET head_sha = ?
WHERE rfc_slug = 'ohm' AND branch_name = ?
""",
(fake.branches[(owner, repo)][branch]["sha"], branch),
)
r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft")
assert r.status_code == 200, r.text
body = r.json()
# The stub uses "Edits to <RFC title>" as the title (per Slice 3).
assert body["title"] == "Edits to OHM"
# ---------------------------------------------------------------------------
# Chat surface refuses cleanly when AI is unavailable for the RFC
# ---------------------------------------------------------------------------
def test_chat_refuses_when_rfc_opted_out_of_ai(app_with_fake_gitea):
"""A chat turn on a branch of an RFC with `models: []` refuses
with 503 same shape as "no providers configured." The refusal
surfaces as a property of the RFC, not a server failure."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_install_two_providers(app)
_set_models_json("ohm", json.dumps([]))
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat",
json={"text": "tighten this", "model": "claude"},
)
assert r.status_code == 503
# ---------------------------------------------------------------------------
# Cache round-trip: frontmatter `models:` flows into models_json
# ---------------------------------------------------------------------------
def test_meta_repo_frontmatter_models_round_trips_through_cache(app_with_fake_gitea):
"""The production path: a meta-repo entry whose frontmatter carries
`models: [claude]` lands in `cached_rfcs.models_json` after the
next `refresh_meta_repo` sweep."""
from fastapi.testclient import TestClient
from app import cache as cache_mod, db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
# Seed a super-draft entry directly on meta-main with a
# `models:` frontmatter field. seed_super_draft doesn't take
# this field, so we write the meta-repo file ourselves and
# let the cache reconciler parse it.
entry_text = (
"---\n"
"slug: ohm\n"
"title: OHM\n"
"state: super-draft\n"
"id: null\n"
"repo: null\n"
"proposed_by: ben\n"
"proposed_at: 2026-05-23\n"
"graduated_at: null\n"
"graduated_by: null\n"
"owners: []\n"
"arbiters: []\n"
"tags: []\n"
"models:\n"
" - claude\n"
"---\n\n"
"The pitch goes here.\n"
)
sha = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")] = {
"content": entry_text, "sha": sha,
}
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha
asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea))
row = db.conn().execute(
"SELECT models_json FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert row is not None
assert row["models_json"] is not None
assert json.loads(row["models_json"]) == ["claude"]
# And an entry without `models:` lands NULL.
no_models_text = entry_text.replace("models:\n - claude\n", "")
sha2 = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
"content": no_models_text.replace("slug: ohm", "slug: beta")
.replace("title: OHM", "title: Beta"),
"sha": sha2,
}
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha2
asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea))
row = db.conn().execute(
"SELECT models_json FROM cached_rfcs WHERE slug = 'beta'"
).fetchone()
assert row is not None
assert row["models_json"] is None
# ---------------------------------------------------------------------------
# entry.py: absent / empty / populated round-trip the parser+serializer
# ---------------------------------------------------------------------------
def test_entry_models_field_absent_vs_empty_distinguished():
"""The absent vs. empty distinction is load-bearing per §6.6.
parse(serialize(x)) preserves None as None and [] as []."""
from app import entry as entry_mod
absent = entry_mod.Entry(slug="x", title="X")
assert absent.models is None
text_absent = entry_mod.serialize(absent)
assert "models:" not in text_absent
round_absent = entry_mod.parse(text_absent)
assert round_absent.models is None
opted_out = entry_mod.Entry(slug="y", title="Y", models=[])
text_empty = entry_mod.serialize(opted_out)
assert "models: []" in text_empty
round_empty = entry_mod.parse(text_empty)
assert round_empty.models == []
populated = entry_mod.Entry(slug="z", title="Z", models=["claude", "gemini"])
text_full = entry_mod.serialize(populated)
round_full = entry_mod.parse(text_full)
assert round_full.models == ["claude", "gemini"]
+86
View File
@@ -44,6 +44,92 @@ rare and surgical and live in the appropriate numbered section per
## State of the codebase ## State of the codebase
### Post-v1: §6.6 per-RFC model availability (UX half)
The first §19.2 candidate settled after v1 shipped. The heavier
per-RFC-model topic explicitly subdivided into "model availability"
(UX-shaped) and "credential delegation + funder role" (security,
billing, rotation); this session folded in the lighter half and
left the heavier one as its own §19.2 entry.
The settlement: a new optional `models:` frontmatter field on the
meta-repo RFC entry, in the same shape as `owners:` / `arbiters:`.
Absent inherits the operator's universe (the §18 `ENABLED_MODELS`
env contract, now reframed as the operator's provisioned set).
Populated lists narrow the picker to the intersection with that
universe, preserving the entry's stated order. An empty list
(`models: []`) opts the RFC out of AI participation entirely —
every AI surface honors the refusal honestly. The first entry of
the resolved list is the RFC's default model; the §10.2 PR-draft
uses it when present and falls back to its existing deterministic
stub when the resolved list is empty.
§6.6 carries the structural rule. §2.1's frontmatter example shows
the field. §8.12 names the picker's option-list source. §10.2
names the model used for drafting. §16 narrows its "model picker"
deferral to the chrome only. §18 reframes `ENABLED_MODELS` as the
operator universe. §19.2's per-RFC-model entry is split — the UX
half is marked *settled* with a pointer to §6.6; the credential
half lives on as its own entry.
Code changes:
- [`backend/migrations/009_per_rfc_models.sql`](../backend/migrations/009_per_rfc_models.sql)
adds `cached_rfcs.models_json` as a nullable column — NULL means
the frontmatter field is absent (inherit the universe), `'[]'`
means the explicit opt-out, `'[...]'` is the populated list. The
absent-vs-empty distinction is load-bearing per §6.6, so this
column cannot collapse to a `NOT NULL DEFAULT '[]'` the way the
other `*_json` columns do.
- [`backend/app/entry.py`](../backend/app/entry.py) grows an
optional `models: list[str] | None` field on the `Entry`
dataclass. The parser uses a sentinel to distinguish "key not in
YAML" from "key set to null" so both round-trip cleanly. The
serializer emits `models:` only when set, preserving the absent
case in canonical entry text.
- [`backend/app/cache.py`](../backend/app/cache.py)'s
`_upsert_cached_rfc` writes `NULL` or `json.dumps(entry.models)`
on the round-trip from frontmatter through the cache.
- [`backend/app/models_resolver.py`](../backend/app/models_resolver.py)
is the new small module that holds the rule. Two functions:
`resolve_models_for_rfc(slug, providers)` returns the resolved
list, and `default_model_for_rfc(slug, providers)` returns the
first entry or empty string. Every AI surface calls into one of
these; the rule lives in one place.
- [`backend/app/api_branches.py`](../backend/app/api_branches.py)
replaces the slug-less `GET /api/models` with the per-RFC
`GET /api/rfcs/{slug}/models` per §6.6. The chat-stream and
reask-change paths now resolve through the per-RFC list and
refuse with 503 when it is empty. Both `default_model` references
inside the module are removed; the resolver replaces them.
- [`backend/app/api_prs.py`](../backend/app/api_prs.py)'s §10.2
`pr-draft` endpoint resolves the RFC's default via the new
helper. The internal `_draft_with_provider` falls back to the
deterministic stub when `default_model` is empty (the §6.6
opt-out case), extending the prior "no providers configured"
fallback symmetrically.
- [`frontend/src/api.js`](../frontend/src/api.js) and
[`frontend/src/components/RFCView.jsx`](../frontend/src/components/RFCView.jsx)
pass the slug to `listModels(slug)`, hitting the new per-RFC
endpoint.
Settlement ships covered by
[`test_per_rfc_models.py`](../backend/tests/test_per_rfc_models.py)
— ten integration tests: absent-frontmatter inheriting the
operator universe, populated-frontmatter narrowing, the
empty-list opt-out, intersection with the operator's provisioned
set, intersection-empty matching the opt-out shape, the §10.2
draft using the RFC default, the §10.2 stub fallback on opt-out,
the chat surface's 503 refusal on opt-out, the cache round-trip
from meta-repo frontmatter through `refresh_meta_repo`, and the
entry parser/serializer round-trip preserving the absent /
empty / populated distinction.
The full test suite is 106/106 green (96 prior + 10 new). No
behavioral change for RFCs without `models:` in frontmatter — the
operator universe is preserved as the default, so existing
deployments see no surface change until a frontmatter edit lands.
### Slice 1 — shipped ### Slice 1 — shipped
The repository scaffolding (`backend/`, `frontend/`, `scripts/`, The repository scaffolding (`backend/`, `frontend/`, `scripts/`,
+2 -2
View File
@@ -71,8 +71,8 @@ export async function withdrawProposal(prNumber) {
// ── Slice 2: active-RFC view (§8) ───────────────────────────────────────── // ── Slice 2: active-RFC view (§8) ─────────────────────────────────────────
export async function listModels() { export async function listModels(slug) {
return jsonOrThrow(await fetch('/api/models')) return jsonOrThrow(await fetch(`/api/rfcs/${slug}/models`))
} }
export async function getRFCMain(slug) { export async function getRFCMain(slug) {
+1 -1
View File
@@ -95,7 +95,7 @@ export default function RFCView({ viewer }) {
useEffect(() => { useEffect(() => {
getRFC(slug).then(setEntry).catch(err => setError(err.message)) getRFC(slug).then(setEntry).catch(err => setError(err.message))
listModels() listModels(slug)
.then(({ models, default: def }) => { .then(({ models, default: def }) => {
setModels(models || []) setModels(models || [])
setSelectedModel(def || models?.[0]?.id || '') setSelectedModel(def || models?.[0]?.id || '')