Post-v1: per-RFC credential delegation (funder role) folded into §6.7
Second §19.2 settlement after v1. New §6.7 alongside §6.6: optional `funder:` frontmatter field names a single gitea_login; a `funder_consents` app-db row records funder-side opt-in; both halves required for the binding to activate (two-key rule). Funder universe replaces — does not augment — the operator universe per-RFC for attribution-clean resolution. Funder role grants zero §6.1/§6.3 authority. Three revocation paths each restore the operator-credentials status quo. §19.2's credential-delegation entry is split: lighter half marked settled with a pointer to §6.7; operational-realities half (mid-call failure, rotation, billing, rate-limit attribution) lives on as its own entry. Test suite is 125/125 green (106 prior + 19 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,9 @@ from . import (
|
||||
db,
|
||||
entry as entry_mod,
|
||||
cache,
|
||||
funder,
|
||||
philosophy,
|
||||
providers as providers_mod,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
@@ -46,6 +48,11 @@ class DeclineBody(BaseModel):
|
||||
comment: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class FunderCredentialBody(BaseModel):
|
||||
provider: str = Field(min_length=1, max_length=40)
|
||||
api_key: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
def make_router(
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
@@ -381,6 +388,67 @@ def make_router(
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §6.7 / §17: per-RFC credential delegation — the funder surface.
|
||||
# /api/users/me/funder is the self-serve read; the two write
|
||||
# endpoints register credentials and the two slug-scoped endpoints
|
||||
# toggle consent. Per §6.7, the funder cannot expand the operator
|
||||
# universe: registering for a provider family the operator has not
|
||||
# enabled is refused. A consent without registered credentials is
|
||||
# also refused since it would be operationally inert.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/users/me/funder")
|
||||
async def get_funder_self(request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
return {
|
||||
"credentials": funder.list_credentials(user.user_id),
|
||||
"consents": funder.list_consents(user.user_id),
|
||||
}
|
||||
|
||||
@router.post("/api/users/me/funder/credentials")
|
||||
async def register_funder_credential(payload: FunderCredentialBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
provider = payload.provider.strip().lower()
|
||||
if provider not in providers_mod.FUNDER_PROVIDER_FAMILIES:
|
||||
raise HTTPException(422, f"Unknown provider `{provider}`")
|
||||
# §6.7: the funder cannot expand the operator universe. The
|
||||
# provider family must back at least one operator-enabled key.
|
||||
operator_family_keys = providers_mod.picker_keys_for_family(provider, list(providers.keys()))
|
||||
if not operator_family_keys:
|
||||
raise HTTPException(409, f"Operator has not enabled any `{provider}` models")
|
||||
funder.upsert_credential(user.user_id, provider, payload.api_key.strip())
|
||||
return {"ok": True, "provider": provider}
|
||||
|
||||
@router.delete("/api/users/me/funder/credentials/{provider}")
|
||||
async def delete_funder_credential(provider: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
provider = provider.strip().lower()
|
||||
funder.delete_credential(user.user_id, provider)
|
||||
return {"ok": True, "provider": provider}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/funder/consent")
|
||||
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
rfc = db.conn().execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §6.7: refuse consent from a user with no registered credentials
|
||||
# — a consent without a universe would be inert and the surface
|
||||
# should fail loudly rather than silently.
|
||||
if not funder.has_any_credentials(user.user_id):
|
||||
raise HTTPException(409, "Register credentials before consenting to fund")
|
||||
funder.add_consent(user.user_id, slug)
|
||||
return {"ok": True, "slug": slug}
|
||||
|
||||
@router.delete("/api/rfcs/{slug}/funder/consent")
|
||||
async def remove_funder_consent(slug: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
funder.remove_consent(user.user_id, slug)
|
||||
return {"ok": True, "slug": slug}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -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, models_resolver
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -576,7 +576,11 @@ def make_router(
|
||||
fetched = await gitea.read_file(owner, repo, path, ref=branch)
|
||||
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
|
||||
|
||||
provider = providers[reask_model]
|
||||
# §6.7: pick the funder-credentialed instance when a consenting
|
||||
# funder is in effect on this RFC; otherwise the operator's.
|
||||
provider = funder.provider_for_rfc(slug, reask_model, providers)
|
||||
if provider is None:
|
||||
raise HTTPException(503, "No AI providers configured")
|
||||
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
|
||||
history = chat_layer.build_history(thread_id)
|
||||
reask_prompt = (
|
||||
@@ -907,7 +911,11 @@ def make_router(
|
||||
if not resolved:
|
||||
raise HTTPException(503, "No AI providers configured")
|
||||
model_key = body.model if body.model in resolved else resolved[0]
|
||||
provider = providers[model_key]
|
||||
# §6.7: pick the funder-credentialed instance when a consenting
|
||||
# funder is in effect on this RFC; otherwise the operator's.
|
||||
provider = funder.provider_for_rfc(slug, model_key, providers)
|
||||
if provider is None:
|
||||
raise HTTPException(503, "No AI providers configured")
|
||||
|
||||
# Fetch the live branch body so the prompt is anchored to
|
||||
# what's in Gitea right now, not the cache. For super-draft,
|
||||
|
||||
+10
-3
@@ -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, models_resolver
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -92,6 +92,7 @@ def make_router(
|
||||
chat_messages = _branch_chat_excerpt(slug, branch)
|
||||
rfc_default_model = models_resolver.default_model_for_rfc(slug, providers)
|
||||
title, description = _draft_with_provider(
|
||||
slug=slug,
|
||||
providers=providers,
|
||||
default_model=rfc_default_model,
|
||||
rfc_title=rfc["title"],
|
||||
@@ -786,6 +787,7 @@ def _branch_chat_excerpt(slug: str, branch: str, limit: int = 40) -> list[dict]:
|
||||
|
||||
def _draft_with_provider(
|
||||
*,
|
||||
slug: str,
|
||||
providers: dict[str, BaseProvider],
|
||||
default_model: str,
|
||||
rfc_title: str,
|
||||
@@ -799,11 +801,16 @@ def _draft_with_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.
|
||||
surface still works; the contributor edits the text. Per §6.7,
|
||||
when a consenting funder is in effect, the provider instance is
|
||||
built from funder credentials; if the funder cannot serve the
|
||||
default model, we also fall back to the stub.
|
||||
"""
|
||||
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()))
|
||||
provider = funder.provider_for_rfc(slug, default_model, providers)
|
||||
if provider is None:
|
||||
return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
|
||||
system = (
|
||||
"You are summarizing a contributor's proposed change to an RFC for an arbiter audience. "
|
||||
"Output exactly two sections in this order: 'TITLE: <one line, structural, spec-voice>' "
|
||||
|
||||
@@ -79,13 +79,16 @@ 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
|
||||
# §6.7: funder_login mirrors the optional `funder:` frontmatter
|
||||
# field. NULL means absent — operator credentials are used.
|
||||
funder_login = entry.funder or 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,
|
||||
models_json, body, body_sha, last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
models_json, funder_login, 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,
|
||||
@@ -99,6 +102,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
arbiters_json = excluded.arbiters_json,
|
||||
tags_json = excluded.tags_json,
|
||||
models_json = excluded.models_json,
|
||||
funder_login = excluded.funder_login,
|
||||
body = excluded.body,
|
||||
body_sha = excluded.body_sha,
|
||||
last_entry_commit_at = datetime('now'),
|
||||
@@ -118,6 +122,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
json.dumps(entry.arbiters),
|
||||
json.dumps(entry.tags),
|
||||
models_json,
|
||||
funder_login,
|
||||
entry.body,
|
||||
body_sha,
|
||||
),
|
||||
|
||||
@@ -45,6 +45,11 @@ class Entry:
|
||||
# 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
|
||||
# §6.7: optional gitea_login naming the user whose registered API
|
||||
# credentials pay for AI calls on this RFC. None means absent —
|
||||
# operator credentials per §18 are used. The binding is inert until
|
||||
# the named user has a funder_consents row (the hybrid two-key rule).
|
||||
funder: str | None = None
|
||||
body: str = ""
|
||||
|
||||
|
||||
@@ -59,6 +64,8 @@ def parse(text: str) -> Entry:
|
||||
models: list[str] | None = None
|
||||
else:
|
||||
models = [str(m) for m in raw_models]
|
||||
raw_funder = fm.get("funder")
|
||||
funder = str(raw_funder).strip() if raw_funder else None
|
||||
return Entry(
|
||||
slug=str(fm.get("slug") or ""),
|
||||
title=str(fm.get("title") or ""),
|
||||
@@ -73,6 +80,7 @@ def parse(text: str) -> Entry:
|
||||
arbiters=list(fm.get("arbiters") or []),
|
||||
tags=list(fm.get("tags") or []),
|
||||
models=models,
|
||||
funder=funder,
|
||||
body=body,
|
||||
)
|
||||
|
||||
@@ -97,6 +105,11 @@ def serialize(entry: Entry) -> str:
|
||||
# is meaningfully different from `models: []` per §6.6.
|
||||
if entry.models is not None:
|
||||
fm["models"] = entry.models
|
||||
# §6.7: emit `funder:` only when set. Empty / None means absent —
|
||||
# operator credentials are used. There is no "explicit opt-out"
|
||||
# second meaning here as with `models:`; one set of semantics.
|
||||
if entry.funder:
|
||||
fm["funder"] = entry.funder
|
||||
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
||||
body = entry.body.lstrip("\n")
|
||||
if body:
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""§6.7: per-RFC credential delegation — the funder role.
|
||||
|
||||
Three responsibilities live here:
|
||||
|
||||
1. The runtime resolver. Given a (slug, picker_key) tuple, return the
|
||||
`BaseProvider` instance that should serve calls. When no consenting
|
||||
funder is in effect, this is the operator's pre-built provider.
|
||||
When a consenting funder is named on the RFC and has registered
|
||||
credentials for the picker_key's provider family, a fresh provider
|
||||
instance is constructed with the funder's credentials.
|
||||
|
||||
2. The universe resolver. `resolve_funder_universe(slug, operator)`
|
||||
returns the funder's per-RFC universe — the subset of operator-
|
||||
enabled picker keys served by provider families the funder has
|
||||
registered. Returns None when no funder is in effect; an empty
|
||||
list when a consenting funder has no registrations intersecting
|
||||
the operator's enabled set.
|
||||
|
||||
3. The consent + credential CRUD. Small helpers that the §17
|
||||
`/api/users/me/funder` endpoints in api.py call into.
|
||||
|
||||
The lighter half (this module) deliberately does NOT handle:
|
||||
|
||||
- Mid-call failure with fallback. Per §6.7, the resolution is
|
||||
attribution-clean: a consenting-funder RFC is paid entirely from
|
||||
funder credentials. If construction fails or the call fails,
|
||||
that's a failure — no silent fallback to operator credentials.
|
||||
- Per-instance caching. Constructs fresh on every call. The cost is
|
||||
real (HTTP client setup) but bounded; caching across calls is
|
||||
deferred until evidence shows the cost bites.
|
||||
- Billing attribution surface, rotation ceremony, rate-limit
|
||||
attribution. All deferred to the operational-realities §19.2
|
||||
candidate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import db, providers as providers_mod
|
||||
from .providers import BaseProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Universe + provider resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_funder_universe(
|
||||
slug: str, operator_providers: dict[str, BaseProvider]
|
||||
) -> list[str] | None:
|
||||
"""The funder's registered universe for this RFC, or None.
|
||||
|
||||
None means no consenting funder is in effect — caller falls back to
|
||||
the operator universe per §6.7. A list (possibly empty) means a
|
||||
consenting funder is in effect; the list is the subset of operator-
|
||||
enabled picker keys for which the funder has supplied a provider-
|
||||
family API key. An empty list means the named-and-consenting
|
||||
funder's registered providers don't intersect the operator's
|
||||
enabled set — the RFC drops into the §6.6 opt-out shape.
|
||||
"""
|
||||
funder_user_id = consenting_funder_user_id(slug)
|
||||
if funder_user_id is None:
|
||||
return None
|
||||
registered = registered_provider_families(funder_user_id)
|
||||
return [
|
||||
picker_key
|
||||
for picker_key in operator_providers
|
||||
if (family := providers_mod.provider_family_for_picker_key(picker_key))
|
||||
and family in registered
|
||||
]
|
||||
|
||||
|
||||
def provider_for_rfc(
|
||||
slug: str,
|
||||
picker_key: str,
|
||||
operator_providers: dict[str, BaseProvider],
|
||||
) -> BaseProvider | None:
|
||||
"""The `BaseProvider` that should serve calls for (slug, picker_key).
|
||||
|
||||
When a consenting funder is named on the RFC and has registered
|
||||
credentials for picker_key's provider family, returns a fresh
|
||||
funder-credentials provider instance. Otherwise returns the
|
||||
operator's pre-built provider. Returns None when picker_key isn't
|
||||
available in the resolved universe — caller refuses per §6.6/§6.7.
|
||||
"""
|
||||
funder_user_id = consenting_funder_user_id(slug)
|
||||
if funder_user_id is None:
|
||||
return operator_providers.get(picker_key)
|
||||
family = providers_mod.provider_family_for_picker_key(picker_key)
|
||||
if family is None:
|
||||
return None
|
||||
row = db.conn().execute(
|
||||
"SELECT api_key FROM user_funder_credentials WHERE user_id = ? AND provider = ?",
|
||||
(funder_user_id, family),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return providers_mod.construct_for_funder(picker_key, row["api_key"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consenting-funder lookup (the two-key §6.7 rule)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def consenting_funder_user_id(slug: str) -> int | None:
|
||||
"""Resolve the consenting funder for an RFC, or None.
|
||||
|
||||
The two-key rule per §6.7: `cached_rfcs.funder_login` names a user
|
||||
AND a `funder_consents` row exists for the (user, slug) pair. The
|
||||
third revocation path (account-disabled) is a structural commitment
|
||||
— when the disable affordance lands per §6.1, it joins this check
|
||||
as a third AND clause; until then, the two-key rule stands alone.
|
||||
"""
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT u.id AS user_id
|
||||
FROM cached_rfcs r
|
||||
JOIN users u ON u.gitea_login = r.funder_login
|
||||
WHERE r.slug = ? AND r.funder_login IS NOT NULL
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
consent = db.conn().execute(
|
||||
"SELECT 1 FROM funder_consents WHERE user_id = ? AND rfc_slug = ?",
|
||||
(row["user_id"], slug),
|
||||
).fetchone()
|
||||
if not consent:
|
||||
return None
|
||||
return row["user_id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential + consent CRUD (called by the §17 funder endpoints)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def registered_provider_families(user_id: int) -> set[str]:
|
||||
"""The provider families this user has registered credentials for."""
|
||||
rows = db.conn().execute(
|
||||
"SELECT provider FROM user_funder_credentials WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return {r["provider"] for r in rows}
|
||||
|
||||
|
||||
def list_credentials(user_id: int) -> list[dict[str, str]]:
|
||||
"""Per-provider registration metadata for the funder surface.
|
||||
|
||||
The API key itself is never returned to the client. The list shape
|
||||
is one row per registered family, with the registration timestamp
|
||||
for the settings page's display.
|
||||
"""
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT provider, created_at, updated_at
|
||||
FROM user_funder_credentials
|
||||
WHERE user_id = ?
|
||||
ORDER BY provider
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{"provider": r["provider"], "created_at": r["created_at"], "updated_at": r["updated_at"]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def upsert_credential(user_id: int, provider: str, api_key: str) -> None:
|
||||
"""Register or replace an API key for a provider family.
|
||||
|
||||
Idempotent. Callers gate the provider against
|
||||
`providers_mod.FUNDER_PROVIDER_FAMILIES` and against the operator's
|
||||
enabled set (per §6.7, the funder cannot expand the operator
|
||||
universe) before calling here.
|
||||
"""
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO user_funder_credentials (user_id, provider, api_key)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, provider) DO UPDATE SET
|
||||
api_key = excluded.api_key,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(user_id, provider, api_key),
|
||||
)
|
||||
|
||||
|
||||
def delete_credential(user_id: int, provider: str) -> bool:
|
||||
"""Remove a registered key. Returns True if a row was deleted.
|
||||
|
||||
Existing `funder_consents` rows are intentionally left in place —
|
||||
the resolved funder universe for affected RFCs simply shrinks. If
|
||||
the funder later re-registers the family, those RFCs resume
|
||||
drawing on the funder's universe without re-consenting.
|
||||
"""
|
||||
cur = db.conn().execute(
|
||||
"DELETE FROM user_funder_credentials WHERE user_id = ? AND provider = ?",
|
||||
(user_id, provider),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def list_consents(user_id: int) -> list[str]:
|
||||
"""The RFC slugs this user has consented to fund. Sorted for stable display."""
|
||||
rows = db.conn().execute(
|
||||
"SELECT rfc_slug FROM funder_consents WHERE user_id = ? ORDER BY rfc_slug",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [r["rfc_slug"] for r in rows]
|
||||
|
||||
|
||||
def add_consent(user_id: int, slug: str) -> None:
|
||||
"""Idempotent funder-side opt-in to fund this RFC.
|
||||
|
||||
Callers gate with `has_any_credentials` so a consent without any
|
||||
registered universe (which would be inert) is refused at the API
|
||||
layer rather than written.
|
||||
"""
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
|
||||
ON CONFLICT(user_id, rfc_slug) DO NOTHING
|
||||
""",
|
||||
(user_id, slug),
|
||||
)
|
||||
|
||||
|
||||
def remove_consent(user_id: int, slug: str) -> bool:
|
||||
"""Withdraw consent per §6.7's first revocation path. Returns True
|
||||
if a row was removed."""
|
||||
cur = db.conn().execute(
|
||||
"DELETE FROM funder_consents WHERE user_id = ? AND rfc_slug = ?",
|
||||
(user_id, slug),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def has_any_credentials(user_id: int) -> bool:
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM user_funder_credentials WHERE user_id = ? LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
@@ -1,4 +1,5 @@
|
||||
"""§6.6 per-RFC model availability — the resolver.
|
||||
"""§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
|
||||
@@ -8,16 +9,22 @@ 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 operator's provisioned universe.
|
||||
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 operator's
|
||||
provisioned universe, preserving the entry's stated order.
|
||||
entry. Resolved list = intersection of the array with the base
|
||||
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 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
|
||||
@@ -27,32 +34,36 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from . import db
|
||||
from . import db, funder
|
||||
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.
|
||||
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7.
|
||||
|
||||
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())
|
||||
# §6.7: the funder universe (if any) replaces the operator universe
|
||||
# as the base set the §6.6 frontmatter intersects against.
|
||||
funder_universe = funder.resolve_funder_universe(slug, providers)
|
||||
base_universe = funder_universe if funder_universe is not None else 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
|
||||
return list(base_universe)
|
||||
try:
|
||||
listed = [str(m) for m in json.loads(row["models_json"])]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return universe
|
||||
return list(base_universe)
|
||||
if not listed:
|
||||
return []
|
||||
return [m for m in listed if m in providers]
|
||||
base_set = set(base_universe)
|
||||
return [m for m in listed if m in base_set]
|
||||
|
||||
|
||||
def default_model_for_rfc(
|
||||
|
||||
@@ -193,3 +193,56 @@ def load_from_config(config) -> dict[str, BaseProvider]:
|
||||
"OPENAI_API_KEY": config.openai_api_key,
|
||||
}
|
||||
return load_providers(env)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §6.7 helpers — per-RFC funder credential plumbing.
|
||||
#
|
||||
# A picker key (the operator-enabled identifier like "claude" or
|
||||
# "gemini-flash") names exactly one provider family (Anthropic, Google,
|
||||
# OpenAI). The funder registers a key per family; the resolver in
|
||||
# funder.py picks the right family for a requested picker key, then
|
||||
# constructs a fresh provider instance keyed on the funder's API key.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FUNDER_PROVIDER_FAMILIES = ("anthropic", "google", "openai")
|
||||
|
||||
|
||||
def provider_family_for_picker_key(picker_key: str) -> str | None:
|
||||
"""Map a picker key to its provider-family name per §6.7.
|
||||
|
||||
Returns 'anthropic' / 'google' / 'openai' or None for keys outside
|
||||
the known variant tables.
|
||||
"""
|
||||
if picker_key in _CLAUDE_VARIANTS:
|
||||
return "anthropic"
|
||||
if picker_key in _GEMINI_VARIANTS:
|
||||
return "google"
|
||||
if picker_key == "openai":
|
||||
return "openai"
|
||||
return None
|
||||
|
||||
|
||||
def picker_keys_for_family(family: str, enabled_picker_keys: list[str]) -> list[str]:
|
||||
"""Subset of `enabled_picker_keys` served by `family`. Preserves the
|
||||
operator's enabled order so the picker reads stably."""
|
||||
return [k for k in enabled_picker_keys if provider_family_for_picker_key(k) == family]
|
||||
|
||||
|
||||
def construct_for_funder(picker_key: str, api_key: str) -> BaseProvider | None:
|
||||
"""Instantiate a provider for a funder-supplied API key per §6.7.
|
||||
|
||||
Mirrors the variant table `load_providers` uses, without the env
|
||||
contract. Returns None for picker keys outside the known families —
|
||||
the funder cannot serve them and the caller treats the (slug,
|
||||
picker_key) request as unavailable.
|
||||
"""
|
||||
if picker_key in _CLAUDE_VARIANTS:
|
||||
default_model, default_name = _CLAUDE_VARIANTS[picker_key]
|
||||
return AnthropicProvider(api_key=api_key, model=default_model, display_name=default_name)
|
||||
if picker_key in _GEMINI_VARIANTS:
|
||||
default_model, default_name = _GEMINI_VARIANTS[picker_key]
|
||||
return GeminiProvider(api_key=api_key, model=default_model, display_name=default_name)
|
||||
if picker_key == "openai":
|
||||
return OpenAIProvider(api_key=api_key)
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user