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>
63 lines
2.4 KiB
Python
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
|