Files
justin c6b1e72130 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>
2026-06-09 20:52:30 -04:00

63 lines
2.4 KiB
Python

"""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