"""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)}