All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
- Added a new function `_first_balanced_json_array` to extract the first complete top-level JSON array from arbitrary text, enhancing robustness in parsing. - Updated the `run_exercise_ai_suggestion` function to raise clear HTTP exceptions for empty responses from the OpenRouter, ensuring better error handling. - Introduced `_flatten_message_content` in the `openrouter_chat` module to handle structured message content from OpenAI, improving compatibility with various content formats. - Incremented the application version to 0.8.156 and updated the changelog to reflect these enhancements, including improved error messages and JSON parsing capabilities.
139 lines
4.5 KiB
Python
139 lines
4.5 KiB
Python
"""
|
||
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
|
||
|
||
|
||
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()
|
||
|
||
|
||
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
|
||
raw: Any = None
|
||
if isinstance(inner, dict):
|
||
raw = inner.get("content")
|
||
if raw is None and inner.get("refusal") is not None:
|
||
raw = inner.get("refusal")
|
||
elif isinstance(inner, str):
|
||
raw = inner
|
||
if raw is None and isinstance(msg0, dict):
|
||
raw = msg0.get("content")
|
||
|
||
content = _flatten_message_content(raw)
|
||
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
|