Files
Onyscribe-Admin/app/services/llm.py
RubenRWU 7d8c226da2 Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:30:26 +02:00

122 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Zugang zu Cortecs (OpenAI-kompatibel + Anthropic-Messages) für Agent und Prompt-Test.
Der Key kommt aus dem geteilten Secret CORTECS_API_KEY (verschlüsselt in der DB) oder der Env CORTECS_API_KEY.
"""
from __future__ import annotations
import os
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Secret, Setting
from app.services import crypto
DEFAULT_BASE = "https://api.cortecs.ai/v1"
class LLMNotConfigured(RuntimeError):
pass
def _setting(db: Session, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value else default
def key_info(db: Session, tenant_id: str | None = None) -> tuple[str, str, str | None]:
"""(key, quelle, fehler). Reihenfolge: geteiltes Secret (DB) → Env CORTECS_API_KEY → Key des Tenants (Rückfall).
Quelle: 'shared' | 'env' | 'tenant' | 'none'. fehler: z. B. nicht entschlüsselbar (SECRET_KEY geändert?)."""
error = None
rows = [("shared", db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == "CORTECS_API_KEY")))]
for source, row in rows:
if row and row.value_enc:
try:
return crypto.decrypt(row.value_enc), source, None
except Exception: # noqa: BLE001 – falscher SECRET_KEY o. Ä.
error = "Geteilter CORTECS_API_KEY in der DB ist nicht entschlüsselbar (SECRET_KEY geändert?) – bitte neu setzen."
env = os.getenv("CORTECS_API_KEY", "").strip()
if env:
return env, "env", error
if tenant_id:
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "CORTECS_API_KEY"))
if row and row.value_enc:
try:
return crypto.decrypt(row.value_enc), "tenant", error
except Exception: # noqa: BLE001
error = error or "CORTECS_API_KEY dieses Tenants ist nicht entschlüsselbar."
return "", "none", error
def api_key(db: Session, tenant_id: str | None = None) -> str:
return key_info(db, tenant_id)[0]
def base_url(db: Session) -> str:
return _setting(db, "cortecs_base_url", DEFAULT_BASE).rstrip("/")
def configured(db: Session, tenant_id: str | None = None) -> bool:
return bool(api_key(db, tenant_id))
def require_key(db: Session, tenant_id: str | None = None) -> str:
k, _, err = key_info(db, tenant_id)
if not k:
raise LLMNotConfigured(err or "CORTECS_API_KEY fehlt – unter Einstellungen → Geteilte Secrets setzen (oder Env CORTECS_API_KEY).")
return k
def list_models(db: Session) -> list[dict]:
"""GET /v1/models – Rohdaten von Cortecs, auf die für die Auswahl nötigen Felder reduziert."""
key = require_key(db)
r = httpx.get(f"{base_url(db)}/models", headers={"Authorization": f"Bearer {key}"}, timeout=30)
r.raise_for_status()
data = r.json()
items = data.get("data") if isinstance(data, dict) else data
out = []
for m in items or []:
if isinstance(m, str):
m = {"id": m}
caps = {k: v for k, v in m.items() if isinstance(v, (bool, list, dict)) or k in ("context_size", "context_length")}
out.append({"id": m.get("id") or m.get("name"), "raw": caps,
"tools": _supports_tools(m)})
return sorted((o for o in out if o["id"]), key=lambda o: o["id"])
def _supports_tools(m: dict) -> bool | None:
"""Cortecs dokumentiert die Feldnamen nicht eindeutig → mehrere plausible Varianten prüfen; None = unbekannt."""
flat = {}
def walk(prefix, v):
if isinstance(v, dict):
for k, x in v.items():
walk(f"{prefix}.{k}".lower(), x)
elif isinstance(v, list):
flat[prefix] = [str(i).lower() for i in v]
else:
flat[prefix] = v
walk("", m)
for k, v in flat.items():
if "tool" in k or "function" in k:
return bool(v)
if isinstance(v, list) and any("tool" in i or "function" in i for i in v):
return True
return None
def summarize(db: Session, system_prompt: str, user_content: str, model: str | None = None, tenant_id: str | None = None) -> dict:
"""Ein Protokoll-Lauf wie im Core (Chat Completions), für den Prompt-Test."""
from openai import OpenAI
client = OpenAI(api_key=require_key(db, tenant_id), base_url=base_url(db), max_retries=2, timeout=300)
model = model or _setting(db, "llm_model", "claude-4-6-sonnet")
resp = client.chat.completions.create(
model=model, temperature=float(_setting(db, "llm_temperature", "0.2")),
max_tokens=int(_setting(db, "llm_max_tokens", "16000")), stream=False,
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}],
)
text = "\n".join(c.message.content for c in resp.choices if c.message and c.message.content).strip()
u = getattr(resp, "usage", None)
return {"text": text, "model": model, "input_tokens": getattr(u, "prompt_tokens", 0), "output_tokens": getattr(u, "completion_tokens", 0),
"truncated": any(getattr(c, "finish_reason", "") in ("length", "max_tokens") for c in resp.choices)}