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>
This commit is contained in:
121
app/services/llm.py
Normal file
121
app/services/llm.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""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)}
|
||||
Reference in New Issue
Block a user