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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- §6.7: per-RFC credential delegation — the funder role.
|
||||
--
|
||||
-- Three pieces hang together:
|
||||
--
|
||||
-- 1. cached_rfcs.funder_login mirrors the optional `funder:` frontmatter
|
||||
-- field. NULL means absent — operator credentials per §18 are used.
|
||||
-- A populated value names the user whose registered credentials pay,
|
||||
-- contingent on a matching consent row below.
|
||||
--
|
||||
-- 2. user_funder_credentials stores per-user, per-provider API keys.
|
||||
-- A user can register at most one key per provider family; the keys
|
||||
-- are stored as-supplied (encryption-at-rest is an operational
|
||||
-- decision, deferred to §19.2's operational-realities half).
|
||||
--
|
||||
-- 3. funder_consents records the funder-side opt-in per-slug. A
|
||||
-- frontmatter `funder:` field naming a user without a matching
|
||||
-- consent row is operationally inert per §6.7's two-key rule.
|
||||
|
||||
ALTER TABLE cached_rfcs ADD COLUMN funder_login TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_funder_credentials (
|
||||
user_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, provider),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS funder_consents (
|
||||
user_id INTEGER NOT NULL,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, rfc_slug),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_funder_consents_slug
|
||||
ON funder_consents (rfc_slug);
|
||||
@@ -0,0 +1,699 @@
|
||||
"""Integration coverage for the §19.2 "per-RFC credential delegation —
|
||||
funder role + grant shape" candidate folded into §6.7.
|
||||
|
||||
The §6.7 settlement adds an optional `funder:` frontmatter field
|
||||
(parallel to `owners:` / `arbiters:` / `models:`) plus a
|
||||
`funder_consents` app-db record. Both halves are required for the
|
||||
binding to be operationally active — the spec calls this the
|
||||
"frontmatter + consent hybrid" two-key rule. When in effect, the
|
||||
funder universe replaces (not augments) the operator universe for the
|
||||
RFC's picker.
|
||||
|
||||
The tests below prove each branch through the API surface and the
|
||||
resolver: the two-key rule under all four combinations, the
|
||||
universe-replaces-not-augments rule, the three revocation paths, the
|
||||
operator-enabled gate on credential registration, the consent-needs-
|
||||
credentials gate, the §10.2 PR-draft and §8.12 chat routing through
|
||||
funder credentials, and the entry parser round-trip for the new
|
||||
frontmatter field.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
SEED_BODY = (
|
||||
"Open Human Model is a framework for representing humans.\n\n"
|
||||
"It defines consent, trait, and agency in compatible terms."
|
||||
)
|
||||
|
||||
|
||||
def _set_funder_login(slug: str, login: str | None) -> None:
|
||||
"""Write `cached_rfcs.funder_login` 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 funder_login = ? WHERE slug = ?",
|
||||
(login, slug),
|
||||
)
|
||||
|
||||
|
||||
def _install_two_providers(app) -> None:
|
||||
"""Operator universe of two picker keys — `claude` (Anthropic family)
|
||||
and `gemini` (Google family). The funder tests exercise the
|
||||
family-aware funder routing, so the operator must enable picker
|
||||
keys from at least two distinct families."""
|
||||
app.state.providers.clear()
|
||||
app.state.providers["claude"] = FakeProvider("operator claude says hello")
|
||||
app.state.providers["gemini"] = FakeProvider("operator gemini says hello")
|
||||
|
||||
|
||||
def _patch_funder_construct(monkeypatch, marker: str = "from-funder-creds"):
|
||||
"""Intercept `providers.construct_for_funder` so tests can verify
|
||||
when funder credentials are actually used. Returns a FakeProvider
|
||||
whose responses are tagged with the marker."""
|
||||
from app import providers as providers_mod
|
||||
funder_provider = FakeProvider(f"TITLE: {marker}\nDESCRIPTION: {marker} description")
|
||||
|
||||
def fake_construct(picker_key, api_key):
|
||||
# Tag the returned provider with the key it was asked for so the
|
||||
# test can assert routing went through the funder layer.
|
||||
funder_provider._last_picker_key = picker_key
|
||||
funder_provider._last_api_key = api_key
|
||||
return funder_provider
|
||||
|
||||
monkeypatch.setattr(providers_mod, "construct_for_funder", fake_construct)
|
||||
return funder_provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Two-key rule: frontmatter + consent both required
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_frontmatter_only_no_consent_falls_back_to_operator(app_with_fake_gitea):
|
||||
"""`funder:` named in frontmatter without a matching `funder_consents`
|
||||
row is operationally inert per §6.7. The resolver returns None for
|
||||
the funder-universe lookup; the picker reads as the operator 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")
|
||||
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)
|
||||
# Frontmatter names alice as funder, but alice has not consented
|
||||
# and has no registered credentials.
|
||||
_set_funder_login("ohm", "alice")
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
assert r.status_code == 200
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_consent_only_no_frontmatter_falls_back_to_operator(app_with_fake_gitea):
|
||||
"""`funder_consents` row without a matching `funder:` frontmatter
|
||||
field is operationally inert per §6.7's two-key rule. The picker
|
||||
reads as the operator universe regardless of what the consenting
|
||||
user has registered."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
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)
|
||||
# Alice has consented and registered, but frontmatter does not
|
||||
# name her as funder.
|
||||
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_both_present_activates_funder_universe(app_with_fake_gitea):
|
||||
"""Frontmatter + consent + credentials match → the picker resolves
|
||||
to the intersection of the operator's enabled keys and the funder's
|
||||
registered families. Alice registered Anthropic only; the picker
|
||||
narrows to `claude`."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
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_funder_login("ohm", "alice")
|
||||
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
# Operator has {claude, gemini}; funder registered Anthropic only.
|
||||
# The intersection is {claude}.
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude"]
|
||||
assert r.json()["default"] == "claude"
|
||||
|
||||
|
||||
def test_neither_present_operator_universe(app_with_fake_gitea):
|
||||
"""The v1 default and status quo: no `funder:` field and no consent
|
||||
rows — the picker is the operator 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)
|
||||
_install_two_providers(app)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Funder universe REPLACES operator universe — not augments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_funder_universe_replaces_operator_universe(app_with_fake_gitea):
|
||||
"""§6.7's attribution-clean rule: the funder universe replaces the
|
||||
operator universe. A funder who registered Anthropic only —
|
||||
irrespective of what the operator otherwise enables — sees the
|
||||
picker narrowed to keys served by Anthropic. The funder cannot
|
||||
expand beyond the operator's enabled set, and the operator's other
|
||||
families are dropped (not added) when the funder is active."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
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) # operator: claude + gemini
|
||||
_set_funder_login("ohm", "alice")
|
||||
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
# The funder is active. Anthropic family alone serves `claude`.
|
||||
# `gemini` (a Google-family key the operator enabled) is dropped
|
||||
# because the funder hasn't registered Google credentials.
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude"]
|
||||
|
||||
|
||||
def test_funder_with_no_matching_family_falls_into_opt_out_shape(app_with_fake_gitea):
|
||||
"""When the consenting funder has no credentials whose family is
|
||||
served by the operator's enabled set, the resolved universe is
|
||||
empty — same shape as `models: []`. AI surfaces refuse honestly."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
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)
|
||||
# Operator universe: only Anthropic-family.
|
||||
app.state.providers.clear()
|
||||
app.state.providers["claude"] = FakeProvider("op-claude")
|
||||
_set_funder_login("ohm", "alice")
|
||||
# Funder has registered Google credentials only — no overlap.
|
||||
funder.upsert_credential(2, "google", "alice-google-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
assert r.json()["models"] == []
|
||||
|
||||
|
||||
def test_funder_intersects_with_models_frontmatter(app_with_fake_gitea):
|
||||
"""§6.6 + §6.7 compose: the resolved list is the §6.6 `models:` list
|
||||
intersected with the funder's registered universe (not the operator
|
||||
universe). If `models: [gemini]` is set but the funder only
|
||||
registered Anthropic, the intersection is empty and the RFC drops
|
||||
into the opt-out shape."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, funder
|
||||
|
||||
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) # operator: claude + gemini
|
||||
_set_funder_login("ohm", "alice")
|
||||
db.conn().execute(
|
||||
"UPDATE cached_rfcs SET models_json = ? WHERE slug = 'ohm'",
|
||||
(json.dumps(["gemini"]),),
|
||||
)
|
||||
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
# §6.6 names {gemini}; funder universe is {claude}; intersection
|
||||
# is empty.
|
||||
assert r.json()["models"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Three revocation paths — each restores operator-credentials status quo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_funder_withdraws_consent_flips_back_to_operator(app_with_fake_gitea):
|
||||
"""§6.7's first revocation path: the funder withdraws consent. The
|
||||
next AI-surface call resolves through the operator universe."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
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_funder_login("ohm", "alice")
|
||||
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
# Sanity: the binding is active and narrows the picker.
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude"]
|
||||
|
||||
# Alice withdraws consent via the §17 endpoint.
|
||||
r = client.delete("/api/rfcs/ohm/funder/consent")
|
||||
assert r.status_code == 200
|
||||
|
||||
# The picker flips back to the operator universe immediately.
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_frontmatter_removal_flips_back_to_operator(app_with_fake_gitea):
|
||||
"""§6.7's second revocation path: an admin/owner/arbiter edits the
|
||||
frontmatter to remove the field. The cache mirror picks up the
|
||||
change and the resolution flips back without touching consent."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
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_funder_login("ohm", "alice")
|
||||
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude"]
|
||||
|
||||
# Frontmatter edit removes the field — the cache mirror reflects
|
||||
# what `_upsert_cached_rfc` would write on the next sweep.
|
||||
_set_funder_login("ohm", None)
|
||||
|
||||
r = client.get("/api/rfcs/ohm/models")
|
||||
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §17 /api/users/me/funder surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_funder_self_surface_lists_credentials_without_api_keys(app_with_fake_gitea):
|
||||
"""The read endpoint must never return the API key itself, only the
|
||||
presence-of-registration metadata. The list is one row per
|
||||
registered family."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
_install_two_providers(app)
|
||||
funder.upsert_credential(2, "anthropic", "secret-anthropic-key")
|
||||
funder.upsert_credential(2, "google", "secret-google-key")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.get("/api/users/me/funder")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
providers_listed = sorted(c["provider"] for c in body["credentials"])
|
||||
assert providers_listed == ["anthropic", "google"]
|
||||
# The API key itself must not appear anywhere in the response —
|
||||
# the surface returns only registration metadata.
|
||||
assert "secret-anthropic-key" not in r.text
|
||||
assert "secret-google-key" not in r.text
|
||||
assert "api_key" not in r.text
|
||||
assert body["consents"] == []
|
||||
|
||||
|
||||
def test_register_credential_refused_when_operator_has_not_enabled_family(app_with_fake_gitea):
|
||||
"""§6.7: the funder cannot expand the operator universe. Registering
|
||||
Google credentials when the operator has only enabled Anthropic
|
||||
keys is refused."""
|
||||
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")
|
||||
# Operator enables only Anthropic-family keys.
|
||||
app.state.providers.clear()
|
||||
app.state.providers["claude"] = FakeProvider("op-claude")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/users/me/funder/credentials",
|
||||
json={"provider": "google", "api_key": "alice-google-key"},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
# Anthropic should be accepted since the operator has enabled
|
||||
# `claude` (an Anthropic-family picker key).
|
||||
r = client.post(
|
||||
"/api/users/me/funder/credentials",
|
||||
json={"provider": "anthropic", "api_key": "alice-anthropic-key"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_consent_refused_without_any_registered_credentials(app_with_fake_gitea):
|
||||
"""§6.7: a consent without any registered credentials would be
|
||||
operationally inert (the funder universe would be empty). The
|
||||
endpoint refuses rather than silently writing the row."""
|
||||
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)
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/funder/consent")
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_consent_endpoint_round_trip(app_with_fake_gitea):
|
||||
"""The opt-in / opt-out cycle through the API surface: register a
|
||||
credential, consent to fund a slug, see it on the self-read, and
|
||||
withdraw."""
|
||||
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)
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
client.post(
|
||||
"/api/users/me/funder/credentials",
|
||||
json={"provider": "anthropic", "api_key": "alice-anthropic-key"},
|
||||
)
|
||||
r = client.post("/api/rfcs/ohm/funder/consent")
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.get("/api/users/me/funder")
|
||||
assert r.json()["consents"] == ["ohm"]
|
||||
|
||||
r = client.delete("/api/rfcs/ohm/funder/consent")
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.get("/api/users/me/funder")
|
||||
assert r.json()["consents"] == []
|
||||
|
||||
|
||||
def test_delete_credential_leaves_consents_intact(app_with_fake_gitea):
|
||||
"""Per §6.7: deleting a credential leaves `funder_consents` rows in
|
||||
place; the resolved funder universe for affected RFCs simply
|
||||
shrinks. If the funder later re-registers, those RFCs resume
|
||||
drawing on the funder's universe without re-consenting."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
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)
|
||||
funder.upsert_credential(2, "anthropic", "key1")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.delete("/api/users/me/funder/credentials/anthropic")
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.get("/api/users/me/funder")
|
||||
body = r.json()
|
||||
assert body["credentials"] == []
|
||||
assert body["consents"] == ["ohm"] # consent survives
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §10.2 PR-draft and chat surfaces route through funder credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pr_draft_routes_through_funder_credentials(app_with_fake_gitea, monkeypatch):
|
||||
"""When a consenting funder is in effect on an RFC, the §10.2 draft
|
||||
endpoint constructs a provider with the funder's API key rather
|
||||
than reusing the operator's instance. The patched
|
||||
`construct_for_funder` records the picker key and the API key it
|
||||
was called with — both must come from the funder's registration."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, funder
|
||||
|
||||
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_funder_login("ohm", "alice")
|
||||
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
funder_provider = _patch_funder_construct(monkeypatch, marker="alice-funded")
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
|
||||
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
|
||||
# The funder-constructed provider was the one called — its
|
||||
# marker comes back in the title.
|
||||
assert r.json()["title"] == "alice-funded"
|
||||
# And the funder's API key was the one used.
|
||||
assert funder_provider._last_api_key == "alice-anthropic-key"
|
||||
assert funder_provider._last_picker_key == "claude"
|
||||
|
||||
|
||||
def test_pr_draft_falls_back_to_stub_when_funder_lacks_default_family(app_with_fake_gitea):
|
||||
"""If the funder is consenting but has not registered credentials
|
||||
for the family that serves the RFC's default model, the §10.2
|
||||
surface falls back to the deterministic stub — same behavior as
|
||||
operator-side opt-out. The lighter half deliberately does not
|
||||
fall back to operator credentials per §6.7's attribution-clean
|
||||
rule."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, funder
|
||||
|
||||
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_funder_login("ohm", "alice")
|
||||
# Funder consented but registered Google only. Operator's default
|
||||
# would have been `claude` (Anthropic family); the resolved
|
||||
# universe collapses to `gemini`, and the §10.2 draft proceeds
|
||||
# against `gemini`. We test the *failure* path: register no
|
||||
# credentials at all and the universe collapses to empty.
|
||||
funder.add_consent(2, "ohm") # add consent without credentials
|
||||
# But the add_consent helper bypasses the API gate; the resolver
|
||||
# still treats this as "consenting funder with no registered
|
||||
# families" — empty universe → opt-out shape.
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
|
||||
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
|
||||
# Empty resolved universe → deterministic stub per Slice 3.
|
||||
assert r.json()["title"] == "Edits to OHM"
|
||||
|
||||
|
||||
def test_chat_stream_routes_through_funder_credentials(app_with_fake_gitea, monkeypatch):
|
||||
"""The §8.12 chat surface also picks up the funder routing. A chat
|
||||
turn on a branch of an RFC with a consenting funder constructs the
|
||||
provider with the funder's API key — same routing as §10.2."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import funder
|
||||
|
||||
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_funder_login("ohm", "alice")
|
||||
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
||||
funder.add_consent(2, "ohm")
|
||||
|
||||
funder_provider = _patch_funder_construct(monkeypatch, marker="from-funder")
|
||||
|
||||
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 == 200, r.text
|
||||
# The funder's API key was the one used by the constructed provider.
|
||||
assert funder_provider._last_api_key == "alice-anthropic-key"
|
||||
assert funder_provider._last_picker_key == "claude"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache round-trip and entry parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_meta_repo_frontmatter_funder_round_trips_through_cache(app_with_fake_gitea):
|
||||
"""The production path: a meta-repo entry whose frontmatter carries
|
||||
`funder: alice` lands in `cached_rfcs.funder_login` after the next
|
||||
`refresh_meta_repo` sweep. Absent frontmatter lands NULL."""
|
||||
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")
|
||||
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"
|
||||
"funder: alice\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 funder_login FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["funder_login"] == "alice"
|
||||
|
||||
# An entry without `funder:` lands NULL.
|
||||
no_funder_text = entry_text.replace("funder: alice\n", "")
|
||||
sha2 = fake._next_sha()
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
|
||||
"content": no_funder_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 funder_login FROM cached_rfcs WHERE slug = 'beta'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["funder_login"] is None
|
||||
|
||||
|
||||
def test_entry_funder_field_round_trip():
|
||||
"""parse(serialize(x)) preserves the funder field — None stays None
|
||||
(no key emitted), populated stays populated. Mirrors the §6.6
|
||||
absent/empty/populated test for `models:`."""
|
||||
from app import entry as entry_mod
|
||||
|
||||
absent = entry_mod.Entry(slug="x", title="X")
|
||||
assert absent.funder is None
|
||||
text_absent = entry_mod.serialize(absent)
|
||||
assert "funder:" not in text_absent
|
||||
round_absent = entry_mod.parse(text_absent)
|
||||
assert round_absent.funder is None
|
||||
|
||||
populated = entry_mod.Entry(slug="y", title="Y", funder="alice")
|
||||
text_populated = entry_mod.serialize(populated)
|
||||
assert "funder: alice" in text_populated
|
||||
round_populated = entry_mod.parse(text_populated)
|
||||
assert round_populated.funder == "alice"
|
||||
Reference in New Issue
Block a user