import json import logging import os import httpx from app.config import settings from app.services import tts_voices logger = logging.getLogger(__name__) # Model ids reach the proxy exactly as they are configured. They used to be # prefixed with `openai/`, which was never about the proxy: it stopped litellm # picking a provider of its own, and litellm stripped the prefix again before # sending. There is no routing left to defeat. class ProxyError(RuntimeError): """A non-2xx answer from the completions proxy. Every call site catches `Exception` and turns it into a 502, so the type matters less than what it carries. Two things are worth carrying: * `status_code`, because `vision_service._probe` sorts refusals by it — a 4xx to a one-token call holding nothing but a white square is the model saying it will not take images, and that verdict is cached, while a 5xx or a timeout says nothing and is not. The SDK's error exposed the same attribute name, so the probe needs no change. * the start of the body, because the proxy explains refusals there and a log line reading only "400" has never once been enough. """ def __init__(self, status_code: int, body: str): self.status_code = status_code self.body = body super().__init__(f"proxy returned {status_code}: {body}") #: Two minutes is longer than any answer a person waits on here has ever #: legitimately taken, and nothing retries underneath (see `chat`), so a #: stalled connection is a stalled request — three of them in extraction, which #: does its own retrying. DEFAULT_TIMEOUT = 120.0 #: What a call gets when it names no timeout. httpx's own default is five #: seconds, which no completion survives, so the choice cannot be left to it. #: Ten minutes is what the SDK gave these same call sites before it was #: removed, and the longest of them drafts a 4000-token article, so it is kept #: rather than lowered — shortening it is a visible change and belongs in its #: own commit. Anything a person is sitting in front of should pass #: DEFAULT_TIMEOUT instead. FALLBACK_TIMEOUT = 600.0 def _endpoint() -> str: """The proxy speaks OpenAI's HTTP API. `LITELLM_API_BASE` is configured as the bare host, and every other hand-rolled call to it in this codebase — embeddings, transcription, speech — appends the `/v1` itself, so this one does too rather than inventing a third convention. With nothing configured the address is OpenAI's own, which is where the SDK went by default and where `text_to_speech` below still goes.""" base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/").removesuffix("/v1") return f"{base}/v1/chat/completions" def _headers(api_key: str | None) -> dict: # An unconfigured deployment should fail at the call, the way every caller # already handles, rather than earlier and louder somewhere else. key = api_key or settings.LITELLM_API_KEY or os.environ.get("OPENAI_API_KEY") or "missing" return {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} def _body(model: str, messages: list[dict], params: dict) -> dict: return {"model": model, "messages": messages, **params} def _content(response: httpx.Response) -> str | None: """The assistant's message, or the failure that explains itself. Nothing in this codebase reads usage, cost, tool calls or logprobs off a completion — nine call sites, all of them `choices[0].message.content` — so the envelope is unwrapped here instead of nine times over. A caller that one day needs more should get the parsed body, not a second return value. """ if response.status_code >= 400: # Read the body before raising: it is where the proxy says *why*, and # it is the difference between a useful log line and a number. raise ProxyError(response.status_code, response.text[:500]) return response.json()["choices"][0]["message"]["content"] def chat(*, model: str, messages: list[dict], api_key: str | None = None, timeout: float | None = None, **params) -> str | None: """One blocking completion, returning the assistant's message text. Extra keyword arguments (`temperature`, `max_tokens`, …) go into the JSON body untouched, so this is the OpenAI request with the boilerplate — URL, key, timeout — filled in once instead of at each call site. No retries: `extract_questions` already makes three attempts of its own and anything retrying underneath would quietly make that nine. """ with httpx.Client(timeout=timeout if timeout is not None else FALLBACK_TIMEOUT) as client: return _content(client.post(_endpoint(), headers=_headers(api_key), json=_body(model, messages, params))) async def achat(*, model: str, messages: list[dict], api_key: str | None = None, timeout: float | None = None, **params) -> str | None: """Async counterpart to `chat`, for the request-path chat endpoints.""" async with httpx.AsyncClient( timeout=timeout if timeout is not None else FALLBACK_TIMEOUT) as client: return _content(await client.post(_endpoint(), headers=_headers(api_key), json=_body(model, messages, params))) EXTRACTION_PROMPT = """You are extracting questions from a pediatric board review exam PDF. These PDFs follow a strict format: 1. A numbered question with a clinical vignette (patient scenario) 2. Five answer options labeled A, B, C, D, E 3. A line "Correct Answer: X" or "Preferred Response: X" where X is the letter of the correct option 4. An explanation paragraph 5. A "Critique:" section with detailed reasoning 6. A "Content Specifications:" section listing the learning objectives Your task: extract every question and return ONLY a JSON object in this exact format: {{"questions": [ {{ "item_number": "", "question_text": "", "question_type": "mcq", "options": ["