Per-tree AI model policy (owner-only admin view)
The operator decides which model providers exist (env / registry — Anthropic,
OpenAI, x.AI, Ollama, several at once). The *tree owner* decides who uses which:
- Members' assistant -> one configured provider (or none)
- Recommender (association/connection finder) -> one configured provider (or none)
- Owner -> may use any configured provider
Backend: two nullable columns on `trees` (ai_member_provider,
ai_recommender_provider) + migration; `configured_llm_providers()` exposes the
registry as {name, model} with no secrets; owner-gated GET/PATCH
/trees/{id}/ai validate names against the configured set. Frontend: owner-only
"AI models" page with a dropdown per role, graceful 403 for non-owners, and a
sidebar link.
Per-model-within-a-provider selection is a follow-up; today each provider maps
to its single configured model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -101,6 +101,22 @@ def build_llm_providers() -> dict[str, LLMProvider]:
|
||||
return providers
|
||||
|
||||
|
||||
def configured_llm_providers() -> list[dict]:
|
||||
"""Configured LLM providers as {name, model} — for the AI admin view (no
|
||||
secrets). Mirrors build_llm_providers() without constructing clients."""
|
||||
s = get_settings()
|
||||
out: list[dict] = []
|
||||
if s.anthropic_api_key:
|
||||
out.append({"name": "anthropic", "model": s.anthropic_model})
|
||||
if s.openai_api_key:
|
||||
out.append({"name": "openai", "model": s.openai_model})
|
||||
if s.xai_api_key:
|
||||
out.append({"name": "xai", "model": s.xai_model})
|
||||
if s.ollama_enabled:
|
||||
out.append({"name": "ollama", "model": s.ollama_model})
|
||||
return out
|
||||
|
||||
|
||||
def get_llm_provider(name: str | None = None) -> LLMProvider:
|
||||
"""The named LLM provider, or the configured default, or Null if unconfigured."""
|
||||
providers = build_llm_providers()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
ai,
|
||||
auth,
|
||||
citations,
|
||||
cleanup,
|
||||
@@ -36,3 +37,4 @@ api_router.include_router(cleanup.router)
|
||||
api_router.include_router(public.router)
|
||||
api_router.include_router(members.router)
|
||||
api_router.include_router(proposals.router)
|
||||
api_router.include_router(ai.router)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Per-tree AI model policy — owner-only admin view."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.ai_policy import TreeAiPolicyRead, TreeAiPolicyUpdate
|
||||
from app.services import ai_policy_service, tree_service
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["ai"])
|
||||
|
||||
|
||||
@router.get("/{tree_id}/ai", response_model=TreeAiPolicyRead)
|
||||
async def get_ai_policy(
|
||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> TreeAiPolicyRead:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
return TreeAiPolicyRead(**await ai_policy_service.get_policy(session, actor=current, tree=tree))
|
||||
|
||||
|
||||
@router.patch("/{tree_id}/ai", response_model=TreeAiPolicyRead)
|
||||
async def update_ai_policy(
|
||||
tree_id: uuid.UUID, data: TreeAiPolicyUpdate, session: SessionDep, current: CurrentUser
|
||||
) -> TreeAiPolicyRead:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
policy = await ai_policy_service.update_policy(
|
||||
session,
|
||||
actor=current,
|
||||
tree=tree,
|
||||
member_provider=data.member_provider,
|
||||
recommender_provider=data.recommender_provider,
|
||||
)
|
||||
return TreeAiPolicyRead(**policy)
|
||||
@@ -36,6 +36,11 @@ class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
|
||||
use_alter=True,
|
||||
)
|
||||
)
|
||||
# Per-tree AI model policy (owner-configured). The names reference configured
|
||||
# providers from the registry; null = that role has no model. The owner may
|
||||
# use any configured provider; these limit members + the recommender.
|
||||
ai_member_provider: Mapped[str | None] = mapped_column(String(32))
|
||||
ai_recommender_provider: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
|
||||
class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConfiguredProvider(BaseModel):
|
||||
name: str
|
||||
model: str
|
||||
|
||||
|
||||
class TreeAiPolicyRead(BaseModel):
|
||||
# The model non-owners' assistant uses (null = none).
|
||||
member_provider: str | None
|
||||
# The model the association/recommendation engine uses (null = none).
|
||||
recommender_provider: str | None
|
||||
# Providers the operator has configured (from env). The owner may use any of
|
||||
# these; the two settings above restrict members and the recommender to one.
|
||||
configured_providers: list[ConfiguredProvider]
|
||||
default_provider: str
|
||||
|
||||
|
||||
class TreeAiPolicyUpdate(BaseModel):
|
||||
member_provider: str | None = None
|
||||
recommender_provider: str | None = None
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Per-tree AI model policy — owner-only. Assigns which configured provider
|
||||
members and the recommender use; the owner may use any configured provider.
|
||||
|
||||
The operator decides which providers exist (env / registry); the tree owner
|
||||
decides who uses which. See app/api/deps.py for the registry.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import configured_llm_providers
|
||||
from app.models.enums import MembershipRole
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.services import privacy
|
||||
from app.services.exceptions import Forbidden
|
||||
|
||||
|
||||
async def _require_owner(session: AsyncSession, *, actor: User, tree: Tree) -> None:
|
||||
role = await privacy.get_membership_role(session, actor.id, tree.id)
|
||||
if role is not MembershipRole.owner:
|
||||
raise Forbidden("only the tree owner can configure AI")
|
||||
|
||||
|
||||
def _names() -> set[str]:
|
||||
return {p["name"] for p in configured_llm_providers()}
|
||||
|
||||
|
||||
async def get_policy(session: AsyncSession, *, actor: User, tree: Tree) -> dict:
|
||||
await _require_owner(session, actor=actor, tree=tree)
|
||||
from app.core.config import get_settings
|
||||
|
||||
return {
|
||||
"member_provider": tree.ai_member_provider,
|
||||
"recommender_provider": tree.ai_recommender_provider,
|
||||
"configured_providers": configured_llm_providers(),
|
||||
"default_provider": get_settings().default_llm_provider,
|
||||
}
|
||||
|
||||
|
||||
async def update_policy(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
actor: User,
|
||||
tree: Tree,
|
||||
member_provider: str | None,
|
||||
recommender_provider: str | None,
|
||||
) -> dict:
|
||||
await _require_owner(session, actor=actor, tree=tree)
|
||||
valid = _names()
|
||||
for value in (member_provider, recommender_provider):
|
||||
if value is not None and value not in valid:
|
||||
raise Forbidden(f"'{value}' is not a configured provider")
|
||||
tree.ai_member_provider = member_provider
|
||||
tree.ai_recommender_provider = recommender_provider
|
||||
await session.commit()
|
||||
await session.refresh(tree)
|
||||
return await get_policy(session, actor=actor, tree=tree)
|
||||
|
||||
|
||||
# --- Resolution helpers (for the future assistant / recommender) -------------
|
||||
|
||||
def provider_name_for_member(tree: Tree) -> str | None:
|
||||
"""Provider an ordinary member's assistant should use, if any."""
|
||||
return tree.ai_member_provider
|
||||
|
||||
|
||||
def provider_name_for_recommender(tree: Tree) -> str | None:
|
||||
return tree.ai_recommender_provider
|
||||
|
||||
|
||||
def provider_name_for_owner(tree: Tree, requested: str | None = None) -> str | None:
|
||||
"""The owner may use any configured provider; default to the requested one."""
|
||||
if requested and requested in _names():
|
||||
return requested
|
||||
return tree.ai_member_provider # fall back to the member model
|
||||
@@ -0,0 +1,26 @@
|
||||
"""tree AI model policy (ai_member_provider, ai_recommender_provider)
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-06-09
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "b2c3d4e5f6a7"
|
||||
down_revision: str | None = "a1b2c3d4e5f6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("trees", sa.Column("ai_member_provider", sa.String(length=32), nullable=True))
|
||||
op.add_column("trees", sa.Column("ai_recommender_provider", sa.String(length=32), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("trees", "ai_recommender_provider")
|
||||
op.drop_column("trees", "ai_member_provider")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Per-tree AI model policy: owner-only, validated against configured providers."""
|
||||
|
||||
from app.core.config import get_settings
|
||||
from tests.conftest import auth, register
|
||||
|
||||
|
||||
async def test_ai_policy_is_owner_only(client):
|
||||
owner = auth(await register(client, "ai-o@ex.com"))
|
||||
editor = auth(await register(client, "ai-x@ex.com"))
|
||||
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/members", json={"email": "ai-x@ex.com", "role": "editor"}, headers=owner
|
||||
)
|
||||
|
||||
g = await client.get(f"/api/v1/trees/{tid}/ai", headers=owner)
|
||||
assert g.status_code == 200
|
||||
assert g.json()["member_provider"] is None
|
||||
assert g.json()["configured_providers"] == [] # nothing configured in tests
|
||||
|
||||
# An editor (not owner) can neither view nor change the policy.
|
||||
assert (await client.get(f"/api/v1/trees/{tid}/ai", headers=editor)).status_code == 403
|
||||
assert (
|
||||
await client.patch(
|
||||
f"/api/v1/trees/{tid}/ai",
|
||||
json={"member_provider": None, "recommender_provider": None},
|
||||
headers=editor,
|
||||
)
|
||||
).status_code == 403
|
||||
|
||||
|
||||
async def test_ai_policy_set_and_validate(client, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "anthropic_api_key", "sk-ant-test")
|
||||
owner = auth(await register(client, "ai-set@ex.com"))
|
||||
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
|
||||
|
||||
g = (await client.get(f"/api/v1/trees/{tid}/ai", headers=owner)).json()
|
||||
assert {p["name"] for p in g["configured_providers"]} == {"anthropic"}
|
||||
|
||||
# Assign the member + recommender model.
|
||||
p = await client.patch(
|
||||
f"/api/v1/trees/{tid}/ai",
|
||||
json={"member_provider": "anthropic", "recommender_provider": "anthropic"},
|
||||
headers=owner,
|
||||
)
|
||||
assert p.status_code == 200 and p.json()["member_provider"] == "anthropic"
|
||||
|
||||
# A provider that isn't configured is rejected.
|
||||
assert (
|
||||
await client.patch(
|
||||
f"/api/v1/trees/{tid}/ai",
|
||||
json={"member_provider": "openai", "recommender_provider": None},
|
||||
headers=owner,
|
||||
)
|
||||
).status_code == 403
|
||||
|
||||
# Clearing is allowed.
|
||||
c = await client.patch(
|
||||
f"/api/v1/trees/{tid}/ai",
|
||||
json={"member_provider": None, "recommender_provider": None},
|
||||
headers=owner,
|
||||
)
|
||||
assert c.status_code == 200 and c.json()["member_provider"] is None
|
||||
Reference in New Issue
Block a user