File size: 7,143 Bytes
1d5c3e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
"""Shared OpenRouter inference adapter for all AI-powered Spaces.

- OpenAI-compatible chat-completions interface.
- Secrets only from environment (HF Space Secrets). Never logged.
- Structured JSON output validated against a spec (shared.validation);
  one controlled repair attempt on invalid output.
- Bounded retries for transient failures only (429/5xx/timeouts),
  honoring Retry-After. No automatic fallback on schema/safety failures;
  deliberate fallback model only for provider-availability failures.
- Telemetry to stdout: provider, model, latency, usage, finish reason,
  error class. Prompt content is never logged.
- LOREIFY_FAKE_AI=1 serves the caller-supplied fixture (local testing
  without a key; never set this on a deployed Space).
"""

import json
import logging
import os
import time

import httpx

from .validation import validate

log = logging.getLogger("lf.ai")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")


class AIUnavailable(Exception):
    """User-safe failure. str(exc) is shown to the user."""


def _cfg():
    return {
        "api_key": os.environ.get("OPENROUTER_API_KEY", ""),
        "base_url": os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
        "model": os.environ.get("OPENROUTER_MODEL", "google/gemini-3.6-flash"),
        "fallback_model": os.environ.get("OPENROUTER_FALLBACK_MODEL", "openai/gpt-4o-mini"),
        "timeout": float(os.environ.get("OPENROUTER_TIMEOUT_SECONDS", "60")),
        # Generous default: reasoning-capable models (e.g. Gemini Flash) spend
        # completion tokens on thinking before the JSON; a tight cap truncates.
        "max_tokens": int(os.environ.get("OPENROUTER_MAX_TOKENS", "6000")),
        "temperature": float(os.environ.get("OPENROUTER_TEMPERATURE", "0.9")),
    }


USER_ERROR = (
    "The generator hit a snag and couldn't finish. Nothing was charged to you — "
    "please try again in a moment."
)
NO_KEY_ERROR = (
    "The generator isn't configured yet (missing API credentials). "
    "If you run this Space, add the OPENROUTER_API_KEY secret."
)


def _post_completion(model: str, messages: list, cfg: dict, max_tokens: int):
    """One provider call with bounded retries for transient failures."""
    headers = {
        "Authorization": f"Bearer {cfg['api_key']}",
        "HTTP-Referer": "https://loreify.ai",
        "X-Title": "Loreify D&D Tools",
        "Content-Type": "application/json",
    }
    body = {
        "model": model,
        "messages": messages,
        "max_tokens": max_tokens,
        "temperature": cfg["temperature"],
        "response_format": {"type": "json_object"},
    }
    delay = 1.5
    last_exc = None
    for attempt in range(3):
        start = time.time()
        try:
            resp = httpx.post(
                f"{cfg['base_url']}/chat/completions",
                headers=headers, json=body, timeout=cfg["timeout"],
            )
        except (httpx.TimeoutException, httpx.TransportError) as exc:
            last_exc = exc
            log.info("call model=%s attempt=%d error_class=%s", model, attempt, type(exc).__name__)
            time.sleep(delay)
            delay *= 2
            continue
        latency = time.time() - start
        if resp.status_code == 429 or resp.status_code >= 500:
            last_exc = AIUnavailable(USER_ERROR)
            retry_after = resp.headers.get("retry-after")
            wait = min(float(retry_after), 15.0) if retry_after and retry_after.isdigit() else delay
            log.info("call model=%s attempt=%d status=%d retry_in=%.1f", model, attempt, resp.status_code, wait)
            time.sleep(wait)
            delay *= 2
            continue
        if resp.status_code != 200:
            log.info("call model=%s status=%d error_class=http", model, resp.status_code)
            raise AIUnavailable(USER_ERROR)
        data = resp.json()
        choice = (data.get("choices") or [{}])[0]
        usage = data.get("usage") or {}
        log.info(
            "call ok provider=openrouter model=%s latency=%.2fs prompt_tokens=%s "
            "completion_tokens=%s finish=%s",
            data.get("model", model), latency,
            usage.get("prompt_tokens"), usage.get("completion_tokens"),
            choice.get("finish_reason"),
        )
        content = (choice.get("message") or {}).get("content") or ""
        if not content:
            raise AIUnavailable(USER_ERROR)
        return content
    raise last_exc if isinstance(last_exc, AIUnavailable) else AIUnavailable(USER_ERROR)


def _parse_json(text: str):
    text = text.strip()
    if text.startswith("```"):
        text = text.strip("`")
        if text.startswith("json"):
            text = text[4:]
    start, end = text.find("{"), text.rfind("}")
    if start == -1 or end == -1:
        raise ValueError("no JSON object found")
    return json.loads(text[start : end + 1])


def generate_json(
    system_prompt: str,
    user_prompt: str,
    spec: dict,
    fixture: dict | None = None,
    max_tokens: int | None = None,
) -> dict:
    """Generate a spec-valid JSON object. Raises AIUnavailable on failure."""
    if os.environ.get("LOREIFY_FAKE_AI") == "1" and fixture is not None:
        time.sleep(0.4)  # simulate latency so loading states are testable
        return fixture

    cfg = _cfg()
    if not cfg["api_key"]:
        raise AIUnavailable(NO_KEY_ERROR)
    tokens = min(max_tokens or cfg["max_tokens"], cfg["max_tokens"])

    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ]

    models = [cfg["model"]] + ([cfg["fallback_model"]] if cfg["fallback_model"] else [])
    content = None
    for i, model in enumerate(models):
        try:
            content = _post_completion(model, messages, cfg, tokens)
            break
        except AIUnavailable:
            if i == len(models) - 1:
                raise
            log.info("falling back model=%s -> %s", model, models[i + 1])
    active_model = model

    # Parse + validate, with one controlled repair attempt.
    for attempt in ("primary", "repair"):
        try:
            data = _parse_json(content)
            problems = validate(data, spec)
        except (ValueError, json.JSONDecodeError) as exc:
            data, problems = None, [f"invalid JSON: {exc}"]
        if not problems:
            return data
        log.info("schema attempt=%s problems=%d", attempt, len(problems))
        if attempt == "repair":
            raise AIUnavailable(USER_ERROR)
        repair_messages = messages + [
            {"role": "assistant", "content": content},
            {
                "role": "user",
                "content": (
                    "Your previous response did not match the required JSON schema. "
                    "Problems: " + "; ".join(problems[:10]) + ". "
                    "Respond again with ONLY the corrected JSON object, no prose."
                ),
            },
        ]
        content = _post_completion(active_model, repair_messages, cfg, tokens)
    raise AIUnavailable(USER_ERROR)  # unreachable