Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00
|
|
|
|
"""
|
|
|
|
|
|
Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 10:09:07 +02:00
|
|
|
|
def _flatten_message_content(content: Any) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
OpenAI-kompatibles Chat-Completion kann `content` als String oder als Liste
|
|
|
|
|
|
strukturierter Blöcke liefern (z. B. Anthropic über OpenRouter/Bedrock).
|
|
|
|
|
|
"""
|
|
|
|
|
|
if content is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
|
return content.strip()
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
parts: List[str] = []
|
|
|
|
|
|
for block in content:
|
|
|
|
|
|
if isinstance(block, str):
|
|
|
|
|
|
parts.append(block)
|
|
|
|
|
|
elif isinstance(block, dict):
|
|
|
|
|
|
t = block.get("type")
|
|
|
|
|
|
txt = block.get("text")
|
|
|
|
|
|
if txt is None and t == "text":
|
|
|
|
|
|
txt = block.get("content")
|
|
|
|
|
|
if isinstance(txt, str):
|
|
|
|
|
|
parts.append(txt)
|
|
|
|
|
|
elif txt is not None:
|
|
|
|
|
|
parts.append(str(txt))
|
|
|
|
|
|
return "".join(parts).strip()
|
|
|
|
|
|
if isinstance(content, dict):
|
|
|
|
|
|
txt = content.get("text")
|
|
|
|
|
|
if txt is None:
|
|
|
|
|
|
txt = content.get("content")
|
|
|
|
|
|
if isinstance(txt, str):
|
|
|
|
|
|
return txt.strip()
|
|
|
|
|
|
if isinstance(txt, list):
|
|
|
|
|
|
return _flatten_message_content(txt)
|
|
|
|
|
|
return str(content).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00
|
|
|
|
class OpenRouterError(Exception):
|
|
|
|
|
|
"""Upstream or transport failure."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def openrouter_chat_completion(
|
|
|
|
|
|
*,
|
|
|
|
|
|
api_key: str,
|
|
|
|
|
|
model: str,
|
|
|
|
|
|
user_content: str,
|
|
|
|
|
|
system_content: Optional[str] = None,
|
|
|
|
|
|
timeout_sec: float = 120.0,
|
|
|
|
|
|
temperature: float = 0.25,
|
|
|
|
|
|
site_url: Optional[str] = None,
|
|
|
|
|
|
app_title: Optional[str] = None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Returns assistant message content (plain string). Caller validates empty responses.
|
|
|
|
|
|
"""
|
|
|
|
|
|
base = (os.getenv("OPENROUTER_BASE_URL") or "").strip().rstrip("/") or "https://openrouter.ai/api/v1"
|
|
|
|
|
|
url = f"{base}/chat/completions"
|
|
|
|
|
|
|
|
|
|
|
|
headers: Dict[str, str] = {
|
|
|
|
|
|
"Authorization": f"Bearer {api_key}",
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
}
|
|
|
|
|
|
referer = (site_url or os.getenv("APP_URL") or "").strip()
|
|
|
|
|
|
if referer:
|
|
|
|
|
|
headers["HTTP-Referer"] = referer
|
|
|
|
|
|
title = (app_title or os.getenv("OPENROUTER_APP_TITLE") or "Shinkan Jinkendo").strip()
|
|
|
|
|
|
if title:
|
|
|
|
|
|
headers["X-Title"] = title
|
|
|
|
|
|
|
|
|
|
|
|
messages: List[Dict[str, str]] = []
|
|
|
|
|
|
if system_content and str(system_content).strip():
|
|
|
|
|
|
messages.append({"role": "system", "content": str(system_content).strip()})
|
|
|
|
|
|
messages.append({"role": "user", "content": user_content})
|
|
|
|
|
|
|
|
|
|
|
|
payload: Dict[str, Any] = {
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"messages": messages,
|
|
|
|
|
|
"temperature": temperature,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with httpx.Client(timeout=timeout_sec) as client:
|
|
|
|
|
|
resp = client.post(url, headers=headers, json=payload)
|
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
|
raise OpenRouterError(str(e)) from e
|
|
|
|
|
|
|
|
|
|
|
|
if resp.status_code >= 400:
|
|
|
|
|
|
detail = ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
j = resp.json()
|
|
|
|
|
|
detail = (
|
|
|
|
|
|
str(j.get("error", {}).get("message"))
|
|
|
|
|
|
if isinstance(j.get("error"), dict)
|
|
|
|
|
|
else str(j.get("message") or j)
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
detail = (resp.text or "")[:600]
|
|
|
|
|
|
raise OpenRouterError(f"HTTP {resp.status_code}: {detail}".strip())
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
|
raise OpenRouterError("Ungueltige JSON-Antwort von OpenRouter") from e
|
|
|
|
|
|
|
|
|
|
|
|
choices = data.get("choices") if isinstance(data, dict) else None
|
|
|
|
|
|
if not choices or not isinstance(choices, list):
|
|
|
|
|
|
raise OpenRouterError("OpenRouter: keine choices in Antwort")
|
|
|
|
|
|
|
|
|
|
|
|
msg0 = choices[0] if choices else {}
|
|
|
|
|
|
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
2026-05-22 10:09:07 +02:00
|
|
|
|
raw: Any = None
|
Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00
|
|
|
|
if isinstance(inner, dict):
|
2026-05-22 10:09:07 +02:00
|
|
|
|
raw = inner.get("content")
|
|
|
|
|
|
if raw is None and inner.get("refusal") is not None:
|
|
|
|
|
|
raw = inner.get("refusal")
|
Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00
|
|
|
|
elif isinstance(inner, str):
|
2026-05-22 10:09:07 +02:00
|
|
|
|
raw = inner
|
|
|
|
|
|
if raw is None and isinstance(msg0, dict):
|
|
|
|
|
|
raw = msg0.get("content")
|
Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00
|
|
|
|
|
2026-05-22 10:09:07 +02:00
|
|
|
|
content = _flatten_message_content(raw)
|
Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00
|
|
|
|
return content.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_openrouter_env() -> tuple[str, str]:
|
|
|
|
|
|
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
|
|
|
|
|
|
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
|
|
|
|
|
|
return key, model
|