203 lines
9.1 KiB
Python
203 lines
9.1 KiB
Python
import os
|
|
import re
|
|
import json
|
|
import time
|
|
import logging
|
|
from openai import OpenAI
|
|
import sentry_sdk
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY')
|
|
FREE_MODELS = [m.strip() for m in os.getenv('OPENROUTER_FREE_MODELS', '').split(',') if m.strip()]
|
|
FALLBACK_MODEL = os.getenv('OPENROUTER_FALLBACK_MODEL', 'anthropic/claude-haiku-4-5')
|
|
|
|
# MVP launch toggle — set OPENROUTER_USE_FREE_MODELS_FIRST=false to skip
|
|
# FREE_MODELS entirely and call FALLBACK_MODEL directly. Added after a
|
|
# real test job took 5m47s because OpenRouter's free tier was rotating
|
|
# through rate-limited/dead models per extraction call, which blew past
|
|
# the frontend's polling timeout. Flip back to "true" (or unset — that's
|
|
# the default) once free-tier reliability is worth the latency again;
|
|
# FREE_MODELS itself is left untouched so switching back is a one-line change.
|
|
USE_FREE_MODELS_FIRST = os.getenv('OPENROUTER_USE_FREE_MODELS_FIRST', 'true').lower() == 'true'
|
|
|
|
|
|
class ExtractorError(Exception):
|
|
"""Raised when every OpenRouter model (free tier + paid fallback) has failed."""
|
|
pass
|
|
|
|
|
|
class RateLimitError(Exception):
|
|
"""Raised internally when an OpenRouter model returns a 429 / rate-limit response."""
|
|
pass
|
|
|
|
|
|
class CreditExhaustedError(Exception):
|
|
"""
|
|
Raised internally when a free model's own credit/quota is used up
|
|
for the period (403 + "credit" in the error body) — distinct from
|
|
RateLimitError: a 429 is transient and worth a short backoff+retry
|
|
on the same model, but an exhausted free-tier quota won't recover
|
|
within this request, so the caller should move to the next model
|
|
immediately instead of wasting a retry.
|
|
"""
|
|
pass
|
|
|
|
|
|
def _parse_json_response(text, model, label):
|
|
"""
|
|
Parses a model's raw text response into JSON via progressively more
|
|
aggressive repairs — ported unchanged from the legacy app.py
|
|
extraction logic: raw parse → regex object extraction →
|
|
trailing-comma/unquoted-key repair.
|
|
|
|
Data flow:
|
|
raw model text → strip code fences →
|
|
json.loads() (fast path) →
|
|
regex-extract the outermost {...} block and retry →
|
|
strip trailing commas + quote bare keys and retry →
|
|
parsed dict returned, or ValueError if all three fail
|
|
"""
|
|
cleaned = re.sub(r'```json|```', '', text).strip()
|
|
|
|
try:
|
|
return json.loads(cleaned)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
match = re.search(r'\{[\s\S]*\}', cleaned)
|
|
if match:
|
|
try:
|
|
return json.loads(match.group(0))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
try:
|
|
fixed = re.sub(r',\s*}', '}', cleaned)
|
|
fixed = re.sub(r',\s*]', ']', fixed)
|
|
fixed = re.sub(r'([{,])\s*([a-zA-Z0-9_]+)\s*:', r'\1"\2":', fixed)
|
|
return json.loads(fixed)
|
|
except json.JSONDecodeError:
|
|
# All three repair passes failed — log what the model actually
|
|
# returned so a real production failure is diagnosable from
|
|
# worker logs alone. Without this, "Could not parse response"
|
|
# gives no way to tell truncation, prose-wrapped JSON, and
|
|
# genuinely malformed output apart after the fact.
|
|
logger.warning(f'{model} unparseable response for {label} (first 1000 chars): {cleaned[:1000]!r}')
|
|
raise ValueError(f'Could not parse response from {model} for {label}')
|
|
|
|
|
|
def _call_openrouter(model, prompt, content):
|
|
"""
|
|
Issues one chat completion request to OpenRouter for `model`.
|
|
`content` is appended to `prompt` when present — most callers
|
|
(via industries.adventure_travel.prompt.build_prompt) already embed
|
|
page content directly into `prompt`, so `content` is typically
|
|
empty; analysis_service's refresh path passes it explicitly too,
|
|
which is harmless (the model just sees the page text once via the
|
|
prompt and, redundantly, once more here).
|
|
|
|
response_format={'type': 'json_object'} constrains the model to
|
|
return valid JSON at the API level, rather than relying purely on
|
|
the prompt's "Return ONLY a valid JSON object" instruction. Added
|
|
after a real production failure: deepseek/deepseek-v4-flash would
|
|
write a full prose comparative analysis (markdown headers, tables,
|
|
"Key Differentiators" sections) instead of the requested JSON
|
|
whenever the competitor's page content didn't clearly match the
|
|
requested trip — the model prioritized being "helpful" about
|
|
explaining the mismatch over following the format instruction. This
|
|
wasn't reproducible with a short synthetic prompt, only the full
|
|
production prompt (many instructions, long page content) — so this
|
|
is a systemic, defensive fix for the whole class of "model deviates
|
|
into prose" failures, not just the one reproduced case. Confirmed
|
|
compatible with every model currently configured (OPENROUTER_FREE_MODELS
|
|
+ OPENROUTER_FALLBACK_MODEL) — a model that rejected the parameter
|
|
would 400 immediately, not the 429s seen during compatibility testing.
|
|
|
|
Data flow:
|
|
model + prompt + content → OpenAI client (OpenRouter base_url) →
|
|
chat.completions.create() → raise RateLimitError on 429/rate-limit
|
|
responses, CreditExhaustedError on a 403 quota-exhausted response
|
|
(caller reacts differently to each — see extract_with_openrouter) →
|
|
raw text → _parse_json_response() → parsed dict returned
|
|
"""
|
|
client = OpenAI(api_key=OPENROUTER_API_KEY, base_url='https://openrouter.ai/api/v1')
|
|
message = f'{prompt}\n\n{content}' if content else prompt
|
|
|
|
try:
|
|
response = client.chat.completions.create(
|
|
model=model,
|
|
messages=[{'role': 'user', 'content': message}],
|
|
temperature=0.1,
|
|
max_tokens=4096,
|
|
response_format={'type': 'json_object'}
|
|
)
|
|
except Exception as e:
|
|
error_str = str(e)
|
|
if '403' in error_str and 'credit' in error_str.lower():
|
|
raise CreditExhaustedError(error_str)
|
|
if '429' in error_str or 'rate_limit' in error_str.lower():
|
|
raise RateLimitError(error_str)
|
|
raise
|
|
|
|
text = response.choices[0].message.content
|
|
return _parse_json_response(text, model, 'extraction')
|
|
|
|
|
|
RETRY_ATTEMPTS_PER_MODEL = 2 # ported from the legacy app.py's extract_with_groq()
|
|
|
|
|
|
def extract_with_openrouter(prompt, content=''):
|
|
"""
|
|
Cycles through free OpenRouter models in sequence, retrying each
|
|
one up to RETRY_ATTEMPTS_PER_MODEL times with backoff before moving
|
|
to the next — ported from the legacy app.py's extract_with_groq(),
|
|
which the Phase 2 refactor had dropped (it moved to the next model
|
|
on the very first error of any kind, including a transient 429,
|
|
burning through the whole free-tier list instantly on any rate-limit
|
|
burst). Falls back to the paid fallback model if every free model
|
|
is exhausted. Never returns None — raises ExtractorError only after
|
|
every model (free + paid) has failed.
|
|
|
|
When USE_FREE_MODELS_FIRST is false, FREE_MODELS is skipped
|
|
entirely and FALLBACK_MODEL is called directly — see that
|
|
constant's comment for why.
|
|
|
|
Data flow:
|
|
prompt + content → for each model in FREE_MODELS + [FALLBACK_MODEL]
|
|
(or just [FALLBACK_MODEL] when USE_FREE_MODELS_FIRST is false):
|
|
up to RETRY_ATTEMPTS_PER_MODEL attempts:
|
|
_call_openrouter() → success: return parsed JSON
|
|
(logging + a Sentry breadcrumb if free models were tried and
|
|
exhausted before the paid fallback succeeded) →
|
|
RateLimitError: sleep 5*(attempt+1)s, retry same model →
|
|
CreditExhaustedError: log, move to next model immediately
|
|
(quota won't recover within this request — retrying wastes time) →
|
|
any other error: retry same model up to the attempt limit,
|
|
then move to next model →
|
|
all models exhausted → raise ExtractorError
|
|
"""
|
|
models_to_try = FREE_MODELS + [FALLBACK_MODEL] if USE_FREE_MODELS_FIRST else [FALLBACK_MODEL]
|
|
|
|
for model in models_to_try:
|
|
for attempt in range(RETRY_ATTEMPTS_PER_MODEL):
|
|
try:
|
|
result = _call_openrouter(model, prompt, content)
|
|
if USE_FREE_MODELS_FIRST and model == FALLBACK_MODEL and FREE_MODELS:
|
|
logger.warning(f'Free models exhausted — used paid fallback: {model}')
|
|
sentry_sdk.capture_message('OpenRouter paid fallback used', level='warning')
|
|
return result
|
|
except RateLimitError:
|
|
wait = 5 * (attempt + 1)
|
|
logger.warning(f'{model} rate limited — waiting {wait}s (attempt {attempt + 1}/{RETRY_ATTEMPTS_PER_MODEL})...')
|
|
time.sleep(wait)
|
|
except CreditExhaustedError:
|
|
logger.warning(f'{model} free tier exhausted — trying next model...')
|
|
break
|
|
except Exception as e:
|
|
logger.warning(f'{model} failed: {str(e)[:200]}')
|
|
if attempt == RETRY_ATTEMPTS_PER_MODEL - 1:
|
|
logger.warning(f'Moving to next model after {model} failed {RETRY_ATTEMPTS_PER_MODEL} times')
|
|
|
|
raise ExtractorError('All OpenRouter models exhausted including paid fallback')
|