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>
122 lines
5.1 KiB
Python
122 lines
5.1 KiB
Python
"""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)}
|