c6b1e72130
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>
78 lines
2.6 KiB
Python
78 lines
2.6 KiB
Python
"""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
|