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
|
||||
|
||||
Reference in New Issue
Block a user