bifroest/tests/test_ai.py
2026-10-27 08:34:17 +01:00

333 lines
16 KiB
Python

"""Modelle und Zugänge: Verwaltung, Auflösung je Team, Antworten über den Adapter (→ Konzept 8.1)."""
import json
import re
import uuid
import httpx
import pytest
from sqlalchemy import select, update
from app.ai import service as ai
from app.ai.models import Model, ProviderKey
from app.ai.provider import GuardrailBlocked, ProviderError, ResponsesProvider
from app.chat.models import Message
from app.core.crypto import Vault
from app.tasks import states
from app.tasks.models import TaskRun
from app.tasks.worker import Worker
from helpers import connect_repo, create_workspace
def make_admin(db, user_id: int) -> None:
from app.identity.models import User
with db.session() as session:
session.get(User, user_id).is_admin = True
@pytest.fixture
def admin(browser, people, db):
make_admin(db, people["anna"])
return browser("anna")
@pytest.fixture
def chat(browser, people, git_repo):
anna = browser("anna")
workspace_id = create_workspace(anna, people["team_demo"])
connect_repo(anna, workspace_id, git_repo)
location = anna.post(f"/workspaces/{workspace_id}/chats").headers["location"]
return anna, workspace_id, int(location.rsplit("/", 1)[1])
def ask(b, chat_id: int, text: str = "Hallo") -> uuid.UUID:
response = b.post(f"/chats/{chat_id}/messages", {"content": text}, headers={"HX-Request": "true"})
assert response.status_code == 200, response.text
return uuid.UUID(re.search(r'/runs/([0-9a-f-]+)/events', response.text).group(1))
def run_of(db, run_id) -> TaskRun:
with db.session() as session:
return session.get(TaskRun, run_id)
# --- Verwaltung ---
def test_nur_plattform_admin(browser, people):
ben = browser("ben")
assert ben.get("/admin/models").status_code == 403
assert ben.post("/admin/providers", {"name": "X", "base_url": "https://x.example"}).status_code == 403
assert ben.post("/admin/keys", {"provider_id": people["provider"], "value": "sk-1234567890"}).status_code == 403
assert "Modelle und Zugänge" not in ben.get("/workspaces").text
def test_key_wird_verschluesselt_und_nie_angezeigt(admin, people, db, settings):
response = admin.post("/admin/keys", {"provider_id": people["provider"], "team_id": people["team_demo"],
"value": "sk-geheim-team-demo-9876"})
assert response.status_code == 200 and "nicht wieder angezeigt" in response.text
page = admin.get("/admin/models").text
assert "sk-geheim" not in page and "…9876" in page
with db.session() as session:
key = session.scalar(select(ProviderKey).where(ProviderKey.team_id == people["team_demo"]))
assert "sk-geheim" not in key.secret
assert Vault(settings.secret_key).decrypt(key.secret) == "sk-geheim-team-demo-9876"
def test_key_ersetzen_statt_doppelt(admin, people, db):
for value in ("sk-erster-key-1111", "sk-zweiter-key-2222"):
admin.post("/admin/keys", {"provider_id": people["provider"], "value": value})
with db.session() as session:
hints = list(session.scalars(select(ProviderKey.hint).where(ProviderKey.team_id.is_(None))))
assert hints == ["2222"]
def test_ohne_master_key_kein_speichern(settings, db, people, browser):
from fastapi.testclient import TestClient
from app.main import create_app
from helpers import Browser
make_admin(db, people["anna"])
with TestClient(create_app(settings.model_copy(update={"secret_key": ""}))) as client:
b = Browser(client).login("anna")
assert "AIW_SECRET_KEY" in b.get("/admin/models").text
response = b.post("/admin/keys", {"provider_id": people["provider"], "value": "sk-1234567890"})
assert response.status_code == 400
@pytest.mark.parametrize("url", ["http://api.example.com/v1", "https://user:pw@api.example.com", "ftp://x", ""])
def test_unzulaessige_basis_url(admin, url):
response = admin.post("/admin/providers", {"name": "X", "base_url": url})
assert response.status_code in (400, 422)
def test_localhost_per_http_erlaubt():
assert ai.check_base_url("http://127.0.0.1:4000/v1/") == "http://127.0.0.1:4000/v1"
def test_modell_anlegen_und_standard_wechseln(admin, people, db):
response = admin.post("/admin/models", {"provider_id": people["provider"], "name": "gpt-6-luna-mini",
"label": "Luna Mini", "active": "1", "supports_tools": "1"})
assert response.status_code == 200, response.text
with db.session() as session:
mini = session.scalar(select(Model).where(Model.name == "gpt-6-luna-mini"))
assert not mini.is_default
admin.post(f"/admin/models/{mini.id}/default")
with db.session() as session:
assert session.scalar(select(Model.name).where(Model.is_default)) == "gpt-6-luna-mini"
def test_standardmodell_nicht_deaktivierbar(admin, people, db):
with db.session() as session:
luna = session.scalar(select(Model).where(Model.is_default))
response = admin.post("/admin/models", {"model_id": luna.id, "provider_id": people["provider"],
"name": "gpt-6-luna"})
assert response.status_code == 400 and "Standardmodell" in response.text
def test_verbindung_testen(admin, people, monkeypatch):
monkeypatch.setattr(ResponsesProvider, "list_models", lambda self, target: ["gpt-6-luna", "andere"])
response = admin.post(f"/admin/providers/{people['provider']}/test", headers={"HX-Request": "true"})
assert "Verbindung ok, 2 Modelle" in response.text and "gpt-6-luna" in response.text
# --- Modellwahl im Workspace ---
def test_workspace_modell_waehlen(chat, people, db, fake_provider, settings, browser):
anna, workspace_id, chat_id = chat
with db.session() as session:
mini = Model(provider_id=people["provider"], name="gpt-6-luna-mini", label="Luna Mini")
session.add(mini)
assert anna.post(f"/workspaces/{workspace_id}/model", {"model_id": mini.id}).status_code == 303
assert "Luna Mini" in anna.get(f"/workspaces/{workspace_id}").text
ask(anna, chat_id)
Worker(db, settings).run_once()
assert fake_provider.calls[-1]["target"].model == "gpt-6-luna-mini"
# Deaktiviert der Plattform-Admin das Modell, gilt wieder der Standard
with db.session() as session:
session.execute(update(Model).where(Model.id == mini.id).values(active=False))
ask(anna, chat_id, "Nochmal")
Worker(db, settings).run_once()
assert fake_provider.calls[-1]["target"].model == "gpt-6-luna"
def test_workspace_modell_nur_admin_und_nur_aktiv(chat, people, db, browser):
anna, workspace_id, _ = chat
with db.session() as session:
off = Model(provider_id=people["provider"], name="alt", label="Alt", active=False)
session.add(off)
assert anna.post(f"/workspaces/{workspace_id}/model", {"model_id": off.id}).status_code == 400
anna.post(f"/workspaces/{workspace_id}/members", {"username": "ben", "role": "member"})
assert browser("ben").post(f"/workspaces/{workspace_id}/model", {"model_id": ""}).status_code == 403
# --- Auflösung und Antworten ---
def test_team_key_hat_vorrang(chat, people, db, settings, fake_provider):
anna, _, chat_id = chat
with db.session() as session:
session.add(ProviderKey(provider_id=people["provider"], team_id=people["team_demo"],
secret=Vault(settings.secret_key).encrypt("sk-team-demo"), hint="demo",
updated_by=people["anna"]))
ask(anna, chat_id)
Worker(db, settings).run_once()
assert fake_provider.calls[-1]["target"].api_key == "sk-team-demo"
def test_antwort_mit_verlauf_und_reasoning(chat, db, settings, fake_provider):
anna, _, chat_id = chat
first = ask(anna, chat_id, "Erste Frage")
Worker(db, settings).run_once()
assert run_of(db, first).status == states.COMPLETED
with db.session() as session:
reply = session.scalar(select(Message).where(Message.role == "assistant"))
assert reply.model == "gpt-6-luna" and reply.usage["total_tokens"] == 7 and reply.usage["context_window"] == 128000
ask(anna, chat_id, "Zweite Frage")
Worker(db, settings).run_once()
sent = fake_provider.calls[-1]["input"]
assert sent[0] == {"role": "user", "content": "Erste Frage"}
# Gespeicherte Elemente gehen unverändert zurück, inklusive verschlüsseltem Reasoning
assert sent[1]["type"] == "reasoning" and sent[1]["encrypted_content"] == "verschluesselt"
assert sent[-1] == {"role": "user", "content": "Zweite Frage"}
assert "Projekt X" in fake_provider.calls[-1]["instructions"]
def test_kein_key_endet_sofort_mit_meldung(chat, db, settings):
anna, _, chat_id = chat
with db.session() as session:
session.execute(ProviderKey.__table__.delete())
run_id = ask(anna, chat_id)
Worker(db, settings).run_once()
run = run_of(db, run_id)
assert run.status == states.FAILED and run.attempts == 1
assert "kein API-Key" in anna.get(f"/runs/{run_id}/answer").text
def test_guardrail_blockiert(chat, db, settings, fake_provider):
anna, _, chat_id = chat
fake_provider.error = GuardrailBlocked("guardrail_prompt_injection", "Prompt Injection erkannt")
run_id = ask(anna, chat_id)
Worker(db, settings).run_once()
assert run_of(db, run_id).status == states.BLOCKED_BY_GUARDRAIL
assert "guardrail_prompt_injection" in anna.get(f"/runs/{run_id}/answer").text
def test_voruebergehender_fehler_wird_wiederholt(chat, db, settings, fake_provider):
anna, _, chat_id = chat
fake_provider.error = ProviderError("503", retryable=True, user_message="ausgelastet")
run_id = ask(anna, chat_id)
Worker(db, settings).run_once()
assert run_of(db, run_id).status == states.QUEUED
def test_abgelehnter_key_ohne_wiederholung(chat, db, settings, fake_provider):
anna, _, chat_id = chat
fake_provider.error = ProviderError("401", retryable=False, user_message="Der API-Key wurde abgelehnt.")
run_id = ask(anna, chat_id)
Worker(db, settings).run_once()
run = run_of(db, run_id)
assert run.status == states.FAILED and run.attempts == 1 and "401" in run.error
# --- Adapter gegen die echte SDK-Schicht, Netz durch MockTransport ersetzt ---
TARGET = ai.Target(base_url="https://api.openai.com/v1", api_key="sk-test", model="gpt-6-luna",
model_label="Luna", supports_reasoning=True)
def sse(*events: dict) -> bytes:
return "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events).encode()
def response_object(status: str = "completed", text: str = "Hallo Welt", **extra) -> dict:
return {"id": "resp_1", "object": "response", "created_at": 0, "model": "gpt-6-luna", "status": status,
"parallel_tool_calls": True, "tool_choice": "auto", "tools": [], "error": None,
"incomplete_details": None, "instructions": None, "metadata": {},
"output": [{"type": "reasoning", "id": "rs_1", "summary": [], "encrypted_content": "enc"},
{"type": "message", "id": "msg_1", "role": "assistant", "status": "completed",
"content": [{"type": "output_text", "text": text, "annotations": []}]}],
"usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0}}, **extra}
def provider_with(handler) -> tuple[ResponsesProvider, list[httpx.Request]]:
seen: list[httpx.Request] = []
def record(request: httpx.Request) -> httpx.Response:
seen.append(request)
return handler(request)
return ResponsesProvider(http_client=httpx.Client(transport=httpx.MockTransport(record))), seen
def test_adapter_streamt_und_sendet_zustandslos():
body = sse({"type": "response.created", "sequence_number": 0, "response": response_object("in_progress")},
{"type": "response.output_text.delta", "sequence_number": 1, "item_id": "msg_1", "output_index": 1,
"content_index": 0, "delta": "Hallo ", "logprobs": []},
{"type": "response.output_text.delta", "sequence_number": 2, "item_id": "msg_1", "output_index": 1,
"content_index": 0, "delta": "Welt", "logprobs": []},
{"type": "response.completed", "sequence_number": 3, "response": response_object()})
provider, seen = provider_with(lambda r: httpx.Response(200, content=body,
headers={"content-type": "text/event-stream"}))
deltas: list[str] = []
result = provider.respond(TARGET, instructions="Sei knapp.", input=[{"role": "user", "content": "Hi"}],
on_text=deltas.append)
assert deltas == ["Hallo ", "Welt"] and result.text == "Hallo Welt"
assert result.items[0]["encrypted_content"] == "enc" and result.usage["total_tokens"] == 7
sent = json.loads(seen[0].content)
assert seen[0].url.path == "/v1/responses" and seen[0].headers["authorization"] == "Bearer sk-test"
assert sent["store"] is False and sent["stream"] is True
assert sent["include"] == ["reasoning.encrypted_content"] and "tools" not in sent
def test_adapter_guardrail_als_http_fehler():
error = {"error": {"code": "guardrail_violation", "type": "guardrail", "message": "Blockiert"}}
provider, _ = provider_with(lambda r: httpx.Response(400, json=error))
with pytest.raises(GuardrailBlocked) as caught:
provider.respond(TARGET, instructions="", input=[], on_text=lambda _: None)
assert caught.value.code == "guardrail_violation"
@pytest.mark.parametrize("status, retryable", [(401, False), (404, False), (429, True), (503, True), (400, False)])
def test_adapter_http_fehler(status, retryable):
provider, _ = provider_with(lambda r: httpx.Response(status, json={"error": {"message": "x", "code": "c"}}))
with pytest.raises(ProviderError) as caught:
provider.respond(TARGET, instructions="", input=[], on_text=lambda _: None)
assert caught.value.retryable is retryable
def test_adapter_failed_im_stream():
failed = response_object("failed", error={"code": "server_error", "message": "kaputt"})
body = sse({"type": "response.failed", "sequence_number": 0, "response": failed})
provider, _ = provider_with(lambda r: httpx.Response(200, content=body,
headers={"content-type": "text/event-stream"}))
with pytest.raises(ProviderError) as caught:
provider.respond(TARGET, instructions="", input=[], on_text=lambda _: None)
assert caught.value.retryable
def test_adapter_netzfehler_wird_wiederholt():
def broken(request):
raise httpx.ConnectError("weg")
provider, _ = provider_with(broken)
with pytest.raises(ProviderError) as caught:
provider.respond(TARGET, instructions="", input=[], on_text=lambda _: None)
assert caught.value.retryable
def test_adapter_tool_aufruf_und_tools_im_request():
call = {"type": "function_call", "id": "fc_1", "call_id": "call_abc", "name": "git_search",
"arguments": "{\"pattern\": \"TIMEOUT\"}", "status": "completed"}
done = response_object(output=[call])
body = sse({"type": "response.output_item.done", "sequence_number": 0, "output_index": 0, "item": call},
{"type": "response.completed", "sequence_number": 1, "response": done})
provider, seen = provider_with(lambda r: httpx.Response(200, content=body,
headers={"content-type": "text/event-stream"}))
tools = [{"type": "function", "name": "git_search", "description": "Suche", "strict": False,
"parameters": {"type": "object", "properties": {"pattern": {"type": "string"}}}}]
history = [{"role": "user", "content": "Hi"},
{"type": "function_call_output", "call_id": "call_0", "output": "alt"}]
result = provider.respond(TARGET, instructions="", input=history, on_text=lambda _: None, tools=tools)
assert result.calls == [call] and result.text == ""
sent = json.loads(seen[0].content)
assert sent["tools"] == tools and sent["tool_choice"] == "auto" and sent["input"] == history