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')