# SPDX-License-Identifier: BUSL-1.1 # Copyright (c) 2024-2026 Ali Sadeghi Aghili """The OpenAI-compatible LLM transport shared by :mod:`llm.router` and :mod:`llm.wizard_llm`. Before this module existed, provider switching lived only in ``llm/wizard_llm.py`` (built for the interactive setup wizard) while the production engine was hardwired to Ollama with no way to swap providers at all. Later, Phase 2 added :class:`OpenAIBackend` and ``AnthropicBackend`` here as two of several interchangeable hosted transports behind :mod:`llm.router`. That premise changed. The Ollama-specific transport and the Anthropic Messages API transport are both gone: **OpenAI-compatible is the only protocol this project speaks**, and ``base_url`` is what selects the endpoint — a user's local ``gpt-oss`` server behind an OpenAI-compatible API, a self-hosted vLLM/llama.cpp instance, or OpenAI's own hosted API are all just an :class:`OpenAIBackend` with a different ``base_url``. Routing across *multiple* such endpoints, with fallback chains and per-task selection, is what :mod:`llm.router` and :mod:`llm.endpoints` are for — this module only supplies the one transport class they route through, plus :class:`MockBackend` for tests. Because every real endpoint is now the same class, trust (whether an endpoint may see schema/business-rule/row data) can no longer be decided by class name — see :mod:`llm.trust` and :func:`llm.router.is_trusted_backend` for why, and how it is decided instead. """ from __future__ import annotations import json import logging import re import time from collections.abc import Iterable, Iterator from typing import TYPE_CHECKING, Any import requests import config as cfg from llm.base import LLMBackend from llm.trust import default_trust_for_url if TYPE_CHECKING: # Annotation-only: llm.router imports llm.base, and llm.router resolves # llm.providers lazily, so keep this out of the runtime import graph. from llm.router import PromptSegments logger = logging.getLogger(__name__) _TIMEOUT: int = 120 _RETRIES: int = 3 _BACKOFF_BASE: int = 2 _FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE) _OUT_OF_SCOPE_SENTINEL = "OUT_OF_SCOPE" #: ``finish_reason`` values an OpenAI-compatible ``/chat/completions`` #: response can carry that this project's contract already names 1:1 (see #: ``observability.llm_status._VALID_FINISH_REASONS``). Anything else is #: preserved, not discarded -- see :func:`_normalize_finish_reason`. _KNOWN_FINISH_REASONS = frozenset({"stop", "length", "content_filter", "tool_calls"}) #: Field names, across OpenAI-compatible servers this project has seen #: documented, that carry a model's reasoning/chain-of-thought text #: separately from its final answer in ``content``. Never verified against #: a live gpt-oss endpoint -- see the module docstring and #: :func:`_extract_reasoning_text`. _REASONING_FIELD_NAMES = ("reasoning_content", "reasoning", "thinking", "thought") #: Literal markers that show up in ``content`` itself when a server's #: OpenAI-compatible shim fails to separate a model's reasoning channel out #: of its response -- gpt-oss's own "harmony" response format uses #: ``<|channel|>``/``<|start|>``/``<|message|>`` control tokens, and several #: other reasoning models (and the servers that front them) use a #: ``...`` block. Kept intentionally narrow (literal, #: well-known markers only) so this never misfires on an ordinary SQL #: response -- see :func:`_content_carries_reasoning_markers`. _REASONING_MARKER_RE = re.compile( r"<\|channel\|>\s*analysis|<\|start\|>assistant<\|channel\|>|", re.IGNORECASE, ) #: Payload keys :func:`load_extra_body` refuses to let ``LLM_EXTRA_BODY`` #: set. Everything here decides *which model is asked what*, or is already #: a first-class setting with its own validation and its own reporting in #: the caller-facing ``llm`` status block: letting an environment variable #: quietly redirect ``model``, rewrite ``messages``, or contradict the #: ``temperature``/``seed`` that ``observability.llm_status`` reports would #: make that block describe a request that was never sent. #: #: ``max_tokens`` is on the list for the same reason and one more: it is #: the setting most likely to be reached for here (see #: ``config.Settings.llm_extra_body_json``'s docstring on reasoning models), #: and ``LLM_NUM_PREDICT`` is where it belongs -- two places setting one #: value, with the status block reading only one of them, is exactly the #: kind of disagreement this project fails loudly on elsewhere. RESERVED_PAYLOAD_KEYS = frozenset({ "model", "messages", "temperature", "top_p", "seed", "max_tokens", "stop", "stream", "n", }) class ExtraBodyConfigError(ValueError): """``LLM_EXTRA_BODY`` is not a JSON object, or names a reserved key.""" def load_extra_body() -> dict[str, Any]: """Parse ``cfg.settings.llm_extra_body_json`` into request fields. Read through ``cfg.settings`` on every call rather than cached, so ``config.override_settings()`` is visible immediately -- this project's configuration contract (see ``config.py``'s module docstring). Returns ------- dict[str, Any] The fields to merge into a chat-completions body. Empty for an unset/blank value, which is the default and sends exactly what this project sent before this setting existed. Raises ------ ExtraBodyConfigError If the value is present but is not valid JSON, is not a JSON object, or names one of :data:`RESERVED_PAYLOAD_KEYS`. Every one of these is a loud failure rather than a silently-ignored field: an operator who set this to turn a model's reasoning off and got no error has every reason to believe it worked, and the symptom when it did not (an empty response, eventually) points nowhere near the typo that caused it. """ raw = (cfg.settings.llm_extra_body_json or "").strip() if not raw: return {} try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise ExtraBodyConfigError( f"LLM_EXTRA_BODY is not valid JSON: {exc}. It must be a JSON " 'object, e.g. {"chat_template_kwargs": {"enable_thinking": false}}' ) from exc if not isinstance(parsed, dict): raise ExtraBodyConfigError( "LLM_EXTRA_BODY must be a JSON object of request fields, not " f"{type(parsed).__name__} -- these are merged into the request " "body, so there is nothing a list or a bare value could mean." ) reserved = sorted(RESERVED_PAYLOAD_KEYS.intersection(parsed)) if reserved: raise ExtraBodyConfigError( f"LLM_EXTRA_BODY may not set {', '.join(repr(k) for k in reserved)} " "-- those are set from their own settings and reported in the " "llm status block, so overriding them here would make that block " "describe a request that was never sent. Use LLM_NUM_PREDICT, " "LLM_TEMPERATURE, LLM_TOP_P, LLM_SEED and LLM_STOP instead." ) return parsed def _normalize_finish_reason(raw_reason: Any) -> str: """Map a raw OpenAI-compatible ``finish_reason`` onto this project's contract. ``"stop"``/``"length"`` -- the two values the original four-value contract (``stop | length | schema_violation | error``) already named -- pass through unchanged. ``"content_filter"`` (a moderation block) and ``"tool_calls"`` (the model tried to call a function instead of answering) are real values several OpenAI-compatible servers emit that the original contract didn't anticipate; this project's contract now recognises them too (see ``observability/llm_status.py``'s ``_VALID_FINISH_REASONS`` and ``docs/api-contract-v2.md`` §6) rather than collapsing them into the generic ``"error"`` bucket, which would make a moderation block indistinguishable, in a week of audit logs, from a dead endpoint. Anything else -- a server-specific string this project has never seen, or a missing/non-string ``finish_reason`` field entirely -- is preserved behind an ``"other:"`` prefix instead of being discarded or forced into ``"error"``: the whole point of deriving this value at all is that an operator reading the log should see what the endpoint actually said, not a value this module made up because it didn't recognise the real one. A missing/absent value becomes ``"other:none"`` for the same reason: silently defaulting it to ``"stop"`` would claim a complete, successful generation that was never confirmed. Examples -------- >>> _normalize_finish_reason("stop") 'stop' >>> _normalize_finish_reason("length") 'length' >>> _normalize_finish_reason("content_filter") 'content_filter' >>> _normalize_finish_reason("tool_calls") 'tool_calls' >>> _normalize_finish_reason("eos_token") 'other:eos_token' >>> _normalize_finish_reason(None) 'other:none' """ if isinstance(raw_reason, str) and raw_reason in _KNOWN_FINISH_REASONS: return raw_reason label = raw_reason if isinstance(raw_reason, str) and raw_reason.strip() else "none" return f"other:{label}" def _extract_reasoning_text(message: Any) -> str: """Best-effort extraction of a model's reasoning text from *message*. Several OpenAI-compatible servers expose a model's chain-of-thought under a field separate from ``content`` -- most commonly ``reasoning_content`` (vLLM's DeepSeek-R1-style convention, also used by some gpt-oss deployments), sometimes ``reasoning`` or ``thinking``/``thought`` on other community servers. This project has never been tested against a live gpt-oss endpoint (see the module docstring), so this check only reads well-known field names and never guesses at new ones. Used purely for *detection* -- see :meth:`OpenAIBackend.generate_with_meta`: the real ``content`` field is always what is returned as the model's answer. This function's result is never substituted for it; silently stripping reasoning prose and hoping what remains is SQL would turn a diagnosable protocol mismatch into a mysterious accuracy problem. """ if not isinstance(message, dict): return "" for key in _REASONING_FIELD_NAMES: value = message.get(key) if isinstance(value, str) and value.strip(): return value.strip() return "" def _content_carries_reasoning_markers(content: str) -> bool: """True when *content* itself contains raw reasoning-channel markup. Some servers -- notably a gpt-oss deployment whose OpenAI-compatible shim doesn't fully separate the model's own "harmony" response format into distinct fields -- can leak internal channel markers (``<|channel|>analysis``, ...) or a ``...`` block straight into ``content`` instead of a separate reasoning field. Never verified against a live endpoint (see the module docstring); kept intentionally narrow (literal, well-known markers only) so it never misfires on an ordinary SQL response. """ return bool(content) and bool(_REASONING_MARKER_RE.search(content)) #: Characters per token for the fallback in :func:`_reasoning_token_stats` #: when a response carries reasoning text but no token counts at all. The #: same rough ratio :func:`prompt_engine.static_prefix.estimate_tokens` #: uses; repeated here because this module must not import #: ``prompt_engine`` (it pulls in the whole knowledge base). _CHARS_PER_TOKEN = 4 def _answer_text(message: Any) -> str: """The text of a response message's ``content``; ``""`` when there is none. A reasoning model that is cut off while still thinking, or that puts everything on its reasoning channel, comes back with ``"content": null`` (or no ``content`` key at all). ``str(None)`` is the four characters ``"None"``, which looked like an answer: it defeated :func:`~observability.llm_status.is_truncated_empty_completion` (so the ``LLM_OUTPUT_TRUNCATED`` message never appeared) and would have counted as answer tokens. Anything that is not a string is "no answer text". Examples -------- >>> _answer_text({"content": "SELECT 1"}) 'SELECT 1' >>> _answer_text({"content": None}), _answer_text({}), _answer_text(None) ('', '', '') >>> _answer_text({"content": 7}) '' """ content = message.get("content") if isinstance(message, dict) else None return content if isinstance(content, str) else "" def _int_or_none(value: Any) -> int | None: """*value* as an ``int`` when it is a real, non-boolean number, else ``None``. Examples -------- >>> _int_or_none(12), _int_or_none(12.0), _int_or_none("12"), _int_or_none(None) (12, 12, None, None) >>> _int_or_none(True) is None True """ if isinstance(value, bool) or not isinstance(value, (int, float)): return None return int(value) def _reasoning_token_stats( body: dict[str, Any], reasoning_text: str, content: str, ) -> tuple[int | None, bool]: """How many of the completion's tokens were reasoning, and whether that is a guess. Three cases, most to least trustworthy: 1. The server says: ``usage.completion_tokens_details.reasoning_tokens`` (OpenAI's own field, which some OpenAI-compatible servers fill in). Returned as is, ``estimated=False`` -- even when it is ``0``. 2. The response carries reasoning text but no count. If ``usage.completion_tokens`` is known, it is split by the share of characters that are reasoning (reasoning text and answer both appear in those tokens, and the total is exact, so only the split is approximate). Otherwise one token per four characters. ``estimated=True``. 3. No reasoning text and no count: ``(None, False)`` -- "not measurable", not "zero". A non-reasoning model and a server that hides its reasoning look the same here. Only lengths are used. The reasoning text itself is never stored or returned: it can quote the prompt, and the audit record that carries this number must not hold row values. Parameters ---------- body: The ``/chat/completions`` response (or its reassembled stream). reasoning_text: The reasoning text from :func:`_extract_reasoning_text`, or ``""``. content: The answer text (``message.content``), or ``""``. Returns ------- tuple[int | None, bool] ``(reasoning_tokens, estimated)``. Examples -------- >>> _reasoning_token_stats( ... {"usage": {"completion_tokens": 90, "completion_tokens_details": {"reasoning_tokens": 80}}}, ... "", "SELECT 1") (80, False) >>> _reasoning_token_stats({"usage": {"completion_tokens": 100}}, "a" * 300, "b" * 100) (75, True) >>> _reasoning_token_stats({}, "a" * 40, "") (10, True) >>> _reasoning_token_stats({"usage": {"completion_tokens": 20}}, "", "SELECT 1") (None, False) """ usage = body.get("usage") if isinstance(body, dict) else None usage = usage if isinstance(usage, dict) else {} details = usage.get("completion_tokens_details") if isinstance(details, dict): reported = _int_or_none(details.get("reasoning_tokens")) if reported is not None: return max(reported, 0), False if not reasoning_text: return None, False completion_tokens = _int_or_none(usage.get("completion_tokens")) if completion_tokens and completion_tokens > 0: total_chars = len(reasoning_text) + len(content) return round(completion_tokens * len(reasoning_text) / total_chars), True return max(1, len(reasoning_text) // _CHARS_PER_TOKEN), True class StreamError(requests.RequestException): """A streamed ``/chat/completions`` response ended, or failed, mid-stream. A :class:`requests.RequestException` so that :meth:`OpenAIBackend.generate_with_meta` retries it like any other transport failure. """ class _StreamAssembler: """Reassemble ``stream=true`` chunks into the body a non-streaming call returns. The streamed protocol splits one response into ``chat.completion.chunk`` objects: ``choices[0].delta`` carries a piece of ``content`` (and, on reasoning models, of a reasoning field), the last choice-bearing chunk carries ``finish_reason``, and -- when the request set ``stream_options={"include_usage": true}`` -- one more chunk with an empty ``choices`` list carries ``usage``. :meth:`body` puts those back together as the ``chat.completion`` object the non-streaming request would have returned (``choices[0].message.content``, the reasoning field under the same name the server streamed it as, ``finish_reason``, ``usage``, ``system_fingerprint``), so everything downstream of the transport -- reasoning detection, finish-reason mapping, the ``llm`` audit block -- reads the same bytes either way. Timestamps are passed in by the caller (``perf_counter`` values), so the class is a pure function of its input and testable on recorded chunks. Examples -------- >>> a = _StreamAssembler() >>> a.feed({"id": "c1", "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant"}}]}, 1.0) >>> a.feed({"choices": [{"index": 0, "delta": {"content": "SEL"}}]}, 1.5) >>> a.feed({"choices": [{"index": 0, "delta": {"content": "ECT 1"}, "finish_reason": "stop"}]}, 1.6) >>> a.feed({"choices": [], "usage": {"prompt_tokens": 5, "completion_tokens": 2}}, 1.7) >>> body = a.body() >>> body["choices"][0]["message"]["content"], body["choices"][0]["finish_reason"] ('SELECT 1', 'stop') >>> body["usage"] {'prompt_tokens': 5, 'completion_tokens': 2} >>> a.first_token_at 1.5 """ def __init__(self) -> None: self._id: Any = None self._model: Any = None self._created: Any = None self._fingerprint: Any = None self._role: str = "assistant" self._content: list[str] = [] self._content_seen = False self._reasoning: dict[str, list[str]] = {} self._finish_reason: Any = None self._finished = False self._usage: dict[str, Any] | None = None self.first_chunk_at: float | None = None self.first_token_at: float | None = None @property def finished(self) -> bool: """True once a chunk carried a ``finish_reason``.""" return self._finished def feed(self, chunk: dict[str, Any], t: float) -> None: """Fold one decoded chunk into the response. Parameters ---------- chunk: One decoded ``data:`` payload. t: When it was received (a ``perf_counter`` value). Raises ------ StreamError If the chunk is a server-sent error object. """ if not isinstance(chunk, dict): raise StreamError("malformed stream chunk (not an object)") error = chunk.get("error") if error: detail = error.get("type") or error.get("code") if isinstance(error, dict) else None raise StreamError(f"endpoint reported an error mid-stream ({detail or 'unspecified'})") if self.first_chunk_at is None: self.first_chunk_at = t for key, attr in ( ("id", "_id"), ("model", "_model"), ("created", "_created"), ("system_fingerprint", "_fingerprint"), ): if chunk.get(key) is not None and getattr(self, attr) is None: setattr(self, attr, chunk[key]) usage = chunk.get("usage") if isinstance(usage, dict) and usage: self._usage = usage choices = chunk.get("choices") or [] choice = next((c for c in choices if isinstance(c, dict) and c.get("index", 0) == 0), None) if choice is None: return delta = choice.get("delta") or {} if isinstance(delta.get("role"), str): self._role = delta["role"] produced = False content = delta.get("content") if content is not None: self._content_seen = True if content: self._content.append(str(content)) produced = True for key in _REASONING_FIELD_NAMES: piece = delta.get(key) if isinstance(piece, str): self._reasoning.setdefault(key, []).append(piece) produced = produced or bool(piece) if produced and self.first_token_at is None: self.first_token_at = t if choice.get("finish_reason") is not None: self._finish_reason = choice["finish_reason"] self._finished = True def body(self) -> dict[str, Any]: """The reassembled ``chat.completion`` body. ``message.content`` is ``None`` when no chunk ever carried a ``content`` field, which is what a non-streaming server returns for a response that is all reasoning -- so the two paths treat that response identically. """ message: dict[str, Any] = { "role": self._role, "content": "".join(self._content) if self._content_seen else None, } for key, parts in self._reasoning.items(): message[key] = "".join(parts) body: dict[str, Any] = { "id": self._id, "object": "chat.completion", "created": self._created, "model": self._model, "choices": [{"index": 0, "message": message, "finish_reason": self._finish_reason}], } if self._fingerprint is not None: body["system_fingerprint"] = self._fingerprint if self._usage is not None: body["usage"] = self._usage return body def _iter_sse_events(lines: Iterable[bytes | str]) -> Iterator[dict[str, Any] | None]: """Decode server-sent-event lines into chunk objects; ``None`` marks ``[DONE]``. Only ``data:`` lines matter; comments (``: keep-alive``), ``event:`` lines and blanks are skipped. Lines arrive as bytes and are decoded as UTF-8 explicitly: ``requests`` would otherwise guess ISO-8859-1 for a ``text/event-stream`` response with no charset, which turns every Persian character into mojibake. Raises ------ StreamError On a ``data:`` line that is not valid JSON. Examples -------- >>> list(_iter_sse_events([b": ping", b"", b'data: {"a": 1}', b"data: [DONE]"])) [{'a': 1}, None] """ for raw in lines: line = raw.decode("utf-8") if isinstance(raw, bytes) else raw if not line.startswith("data:"): continue payload = line[5:].strip() if not payload: continue if payload == "[DONE]": yield None return try: yield json.loads(payload) except json.JSONDecodeError as exc: raise StreamError("malformed stream chunk (invalid JSON)") from exc def _strip_fences(text: str) -> str: """Remove the outermost markdown code fence from *text*, if present.""" m = _FENCE_RE.search(text) return m.group(1).strip() if m else text.strip() def _find_json_substring(text: str) -> str: """Return the substring starting from the first ``{`` or ``[`` character.""" for i, ch in enumerate(text): if ch in ("{", "["): return text[i:] raise ValueError(f"No JSON object found in response. First 200 chars: {text[:200]!r}") def parse_json_response(text: str) -> dict | list: """Best-effort JSON extraction: strip fences, find the start, parse. Shared fallback for :meth:`LLMBackend.generate_structured`'s default implementation (string-parse a plain-text response) when a provider has no native constrained-decoding path. Uses :class:`json.JSONDecoder.raw_decode` rather than a plain ``json.loads`` on the whole tail: a model that answers "here is the JSON: {...} let me know if you need anything else" produces valid JSON followed by trailing prose, which ``json.loads`` rejects outright (``Extra data``) even though the object itself parsed fine. ``raw_decode`` parses just the first JSON value and ignores whatever follows it. Raises ------ ValueError If no valid JSON can be extracted from *text*. Examples -------- >>> parse_json_response('```json\\n{"a": 1}\\n```') {'a': 1} >>> parse_json_response('here is the result: {"a": 1} done') {'a': 1} """ cleaned = _strip_fences(text) json_str = _find_json_substring(cleaned) try: obj, _end = json.JSONDecoder().raw_decode(json_str) return obj except json.JSONDecodeError as exc: raise ValueError(f"JSON parse error: {exc}. Text: {json_str[:200]!r}") from exc _JSON_SPACE = " \t\r\n" def _after_space(text: str, pos: int) -> int: """Index of the first character at or after *pos* that is not JSON whitespace.""" while pos < len(text) and text[pos] in _JSON_SPACE: pos += 1 return pos def _complete_strings(text: str, pos: int, decoder: json.JSONDecoder) -> list[str]: """The complete strings of the JSON array whose first element starts at or after *pos*. Stops at the closing bracket, at the first element that is cut off, and at anything that is not a comma. Returns ``[]`` when an element is not a string: a list that holds numbers or objects is not a flat list of text. """ items: list[str] = [] while True: pos = _after_space(text, pos) if pos >= len(text) or text[pos] == "]": return items try: value, pos = decoder.raw_decode(text, pos) except json.JSONDecodeError: return items if not isinstance(value, str): return [] items.append(value) pos = _after_space(text, pos) if text[pos:pos + 1] != ",": return items pos += 1 def salvage_string_list(text: str, key: str | None = None) -> list[str]: r"""The complete strings of a flat JSON list that the answer was cut off in. A model that loops on the same few phrases until ``max_tokens`` stops it leaves JSON that never closes (``{"aliases": ["a", "b", "a", "c``). Every string before the cut is a whole, valid element, and is returned; the element being written when the answer ended is not. The list is the array itself, or, in an object, the array under *key* (the first array when *key* is ``None``). Only a **flat list of strings** is ever salvaged. A list with a number, an object or another list in it gives ``[]``, and so does an answer with no list: a cut-off SQL statement, a rule or a ``{"question": ..., "sql": ...}`` pair is not made usable by keeping its first half. Call this for answers that are lists of words, such as table aliases, and nothing else. Parameters ---------- text: The raw answer: fenced or not, with or without prose in front. key: The key of the object member that holds the list; ``None`` takes the first member that is a list. Returns ------- list[str] The complete strings in order, repeats included (deduplicate with :func:`core.wizard_drafts.tidy_aliases`); ``[]`` when there are none. Raises ------ Nothing. This function never raises. Examples -------- >>> salvage_string_list('{"aliases": ["a", "b", "a", "c', "aliases") ['a', 'b', 'a'] >>> salvage_string_list('["x", "y", "z') ['x', 'y'] >>> salvage_string_list('```json\n{"aliases": ["one", "two"], "description": "cut off in') ['one', 'two'] >>> salvage_string_list(r'{"aliases": ["say \"hi\"", "café", "c', "aliases") ['say "hi"', 'café'] >>> salvage_string_list('{"aliases": ["معامله", "سفارش فروش", "قرارد') ['معامله', 'سفارش فروش'] >>> salvage_string_list('{"aliases": ["a", "b"]}'), salvage_string_list('{"aliases": []}') (['a', 'b'], []) >>> salvage_string_list('{"aliases": ["cut in the first strin') [] >>> salvage_string_list('{"aliases": ["a", {"x": 1}, "b"') [] >>> salvage_string_list('{"sql": "SELECT a FROM t WHERE x = ') [] >>> salvage_string_list('{"other": ["p"], "aliases": ["q"', "aliases") ['q'] >>> salvage_string_list("no json here") [] """ try: body = _find_json_substring(_strip_fences(text)) except ValueError: return [] decoder = json.JSONDecoder() if body[0] == "[": return _complete_strings(body, 1, decoder) pos = 1 # inside the object, after "{" while True: pos = _after_space(body, pos) try: name, pos = decoder.raw_decode(body, pos) except json.JSONDecodeError: return [] pos = _after_space(body, pos) if not isinstance(name, str) or body[pos:pos + 1] != ":": return [] pos = _after_space(body, pos + 1) if body[pos:pos + 1] == "[" and (key is None or name == key): return _complete_strings(body, pos + 1, decoder) try: _value, pos = decoder.raw_decode(body, pos) except json.JSONDecodeError: return [] pos = _after_space(body, pos) if body[pos:pos + 1] != ",": return [] pos += 1 class OpenAIBackend(LLMBackend): """The one real transport: an OpenAI-compatible ``/chat/completions`` endpoint. Works unchanged against OpenAI's own hosted API, a self-hosted ``gpt-oss``/vLLM/llama.cpp server, or LM Studio — anything that speaks the OpenAI chat-completions wire format, selected entirely by *base_url*. Mirrors the production-grade contract the retired Ollama-specific transport used to provide on its own: transport retries with exponential back-off, the ``ValueError("OUT_OF_SCOPE")`` sentinel, and deterministic-decoding request fields — none of that is specific to Ollama, so none of it should have been lost when Ollama was. Parameters ---------- model: Model identifier, e.g. ``"gpt-4o-mini"`` or a local server's own tag (``"gpt-oss-20b"``). api_key: Bearer token. Empty is valid — many self-hosted OpenAI-compatible servers don't check it. base_url: API base URL. Defaults to ``https://api.openai.com/v1``. trusted: Whether this endpoint may see schema/business-rule/row data. When ``None`` (the default), resolved from *base_url* via :func:`~llm.trust.default_trust_for_url` — loopback/private/``.local`` addresses are trusted by default, everything else (including the factory-default ``https://api.openai.com/v1``) is not. An explicit ``True``/``False`` always wins over that default. See :func:`~llm.router.is_trusted_backend`. retries: Number of transport-level retries with exponential back-off. timeout: Per-request timeout in seconds. Examples -------- >>> OpenAIBackend(model="m", api_key="k", base_url="http://localhost:8000/v1").trusted True >>> OpenAIBackend(model="m", api_key="k").trusted False >>> OpenAIBackend(model="m", api_key="k", base_url="http://localhost:8000/v1", trusted=False).trusted False """ def __init__( self, model: str, api_key: str, base_url: str = "https://api.openai.com/v1", *, trusted: bool | None = None, retries: int = _RETRIES, timeout: int = _TIMEOUT, ) -> None: self._model = model self._api_key = api_key self._base_url = base_url.rstrip("/") self._trusted = trusted if trusted is not None else default_trust_for_url(self._base_url) self._retries = retries self._timeout = timeout @classmethod def from_settings(cls) -> "OpenAIBackend": """Build the trivial single-endpoint backend from :mod:`config`. Reads ``cfg.settings.openai_base_url`` / ``openai_model`` / ``openai_api_key`` — the plain ``OPENAI_*`` variables, with no ``LLM_ENDPOINTS``/``LLM_ROUTES`` multi-endpoint configuration involved. Used wherever a single, config-driven backend is enough on its own: ``llm/wizard_llm.py``, ``app.py``'s REPL, ``eval/cli.py --live``. :meth:`~llm.router.LLMRouter.from_settings` does NOT use this — it goes through :mod:`llm.endpoints` instead, so a multi-endpoint deployment is honoured for production traffic even though these simpler call sites only ever need the one endpoint. Examples -------- >>> import config as cfg >>> with cfg.override_settings(openai_model="m", openai_api_key="k"): ... backend = OpenAIBackend.from_settings() >>> backend.name 'openai:m' """ return cls( model=cfg.settings.openai_model, api_key=cfg.settings.openai_api_key, base_url=cfg.settings.openai_base_url, ) @property def name(self) -> str: return f"openai:{self._model}" @property def trusted(self) -> bool: return self._trusted @property def endpoint(self) -> str | None: return self._base_url @property def _headers(self) -> dict[str, str]: headers = {"Content-Type": "application/json"} if self._api_key: headers["Authorization"] = f"Bearer {self._api_key}" return headers def _build_payload(self, prompt: str) -> dict[str, Any]: """Build the ``/chat/completions`` request body for *prompt*. Every sampling knob is read from :mod:`config` at call time, same as the retired Ollama transport, and for the same reason: the same question, run twice, must produce byte-identical SQL, which needs ``temperature=0``, ``top_p=1``, and a fixed ``seed`` sent on every request. Unlike Ollama's own request shape (a nested ``options`` object), the OpenAI-compatible wire format sends these as top-level sibling fields of ``model``/``messages``. ``seed`` is honoured by vLLM and llama.cpp; OpenAI's own hosted API accepts the field but does not guarantee determinism from it (see its docs on ``system_fingerprint``) — sent regardless, since it is never harmful, and the caller-facing status block reports whether the endpoint *claimed* to honour it rather than asserting determinism this module cannot verify (see ``observability.llm_status.build_llm_status``'s ``seed_honored``). Examples -------- >>> backend = OpenAIBackend(model="m", api_key="k") >>> payload = backend._build_payload("hello") >>> payload["model"], payload["messages"] ('m', [{'role': 'user', 'content': 'hello'}]) >>> sorted(k for k in payload if k not in ("model", "messages")) ['max_tokens', 'seed', 'temperature', 'top_p'] """ payload: dict[str, Any] = { "model": self._model, "messages": [{"role": "user", "content": prompt}], "temperature": cfg.settings.llm_temperature, "top_p": cfg.settings.llm_top_p, "seed": cfg.settings.llm_seed, "max_tokens": cfg.settings.llm_num_predict, } if cfg.settings.llm_stop: payload["stop"] = list(cfg.settings.llm_stop) # Merged last, but unable to reach anything above it: # load_extra_body rejects every key set here (RESERVED_PAYLOAD_KEYS) # rather than letting the merge win, so this can only ever ADD # server-specific fields -- never quietly redirect the request. payload.update(load_extra_body()) return payload def _post_streaming( self, payload: dict[str, Any], start: float, ) -> tuple[dict[str, Any], int, int | None]: """POST *payload* (already ``stream=true``) and reassemble the response. Parameters ---------- payload: The request body, with ``stream`` and ``stream_options`` set. start: ``time.perf_counter()`` taken just before the request, so the time to first token is measured from the same origin as ``total_ms``. Returns ------- tuple[dict[str, Any], int, int | None] ``(body, http_status, ttft_ms)`` -- *body* is the ``chat.completion`` object :class:`_StreamAssembler` rebuilt, *ttft_ms* the time to the first content or reasoning token (or to the first chunk of any kind when the response had no token at all). Raises ------ requests.Timeout If the whole call takes longer than the backend's timeout (the per-read timeout alone would let a slow but steady stream run for ever, which the non-streaming request cannot). StreamError If the stream carries an error object, a malformed chunk, or ends before a ``finish_reason`` or ``[DONE]``. requests.HTTPError For a non-2xx status, exactly as the non-streaming call. """ resp = requests.post( f"{self._base_url}/chat/completions", headers=self._headers, json=payload, timeout=self._timeout, stream=True, ) try: resp.raise_for_status() assembler = _StreamAssembler() done = False # chunk_size=64, not requests' default of 512: on a response that # is not chunk-encoded (close-delimited), a read of N bytes # blocks until N have arrived, which would hold back the first # token's timestamp -- the one number this path exists to # measure -- by however long the next tokens take. An SSE event # is longer than 64 bytes, so the first one is never held back; # byte-at-a-time (1) fixes the same thing but costs ~7x the CPU # (measured: 343 vs 65 ms per 2000-event response, against 53 at # 512). A chunk-encoded response, which is what vLLM and # llama.cpp send, yields as each piece arrives at any size. for event in _iter_sse_events(resp.iter_lines(chunk_size=64)): now = time.perf_counter() if now - start > self._timeout: raise requests.Timeout( f"streamed response exceeded the {self._timeout}s timeout" ) if event is None: done = True break assembler.feed(event, now) if not (done or assembler.finished): raise StreamError("stream ended before the response was complete") first = assembler.first_token_at if first is None: first = assembler.first_chunk_at ttft_ms = round((first - start) * 1000) if first is not None else None return assembler.body(), resp.status_code, ttft_ms finally: resp.close() def warm_prefix(self, prefix: str, *, timeout: float | None = None) -> dict[str, Any]: """Send *prefix* alone as a one-token request, to prime the server's prefix cache. The body is the one :meth:`_build_payload` builds for a real request (model, sampling fields, ``LLM_EXTRA_BODY``), with ``max_tokens`` forced to ``1`` and ``temperature`` to ``0``, so the server computes -- and a server with prefix caching keeps -- the KV cache for exactly the tokens a real request starts with. One attempt, no retries, and always non-streaming: a warm-up that fails is simply replaced by the first real question. Parameters ---------- prefix: The static prompt prefix, byte for byte what the real requests start with (:func:`llm.router.build_prompt_segments`). timeout: HTTP timeout in seconds; the backend's own when ``None``. Returns ------- dict[str, Any] ``{"prompt_tokens", "cached_tokens", "endpoint_status"}``. The first two are ``None`` when the server does not report them (``cached_tokens`` needs vLLM's ``--enable-prompt-tokens-details``). Never the prompt or the response text. Raises ------ requests.RequestException Any transport failure, a timeout, or a non-2xx status. ExtraBodyConfigError If ``LLM_EXTRA_BODY`` is invalid. Examples -------- >>> from unittest.mock import MagicMock, patch >>> reply = MagicMock(status_code=200) >>> reply.json.return_value = {"usage": {"prompt_tokens": 4600}} >>> with patch("llm.providers.requests.post", return_value=reply) as post: ... info = OpenAIBackend(model="m", api_key="k").warm_prefix("PREFIX") >>> post.call_args.kwargs["json"]["max_tokens"], post.call_args.kwargs["json"]["messages"] (1, [{'role': 'user', 'content': 'PREFIX'}]) >>> info {'prompt_tokens': 4600, 'cached_tokens': None, 'endpoint_status': 200} """ payload = self._build_payload(prefix) payload["max_tokens"] = 1 payload["temperature"] = 0 resp = requests.post( f"{self._base_url}/chat/completions", headers=self._headers, json=payload, timeout=timeout if timeout is not None else self._timeout, ) resp.raise_for_status() body = resp.json() usage = body.get("usage") if isinstance(body, dict) else None usage = usage if isinstance(usage, dict) else {} details = usage.get("prompt_tokens_details") details = details if isinstance(details, dict) else {} return { "prompt_tokens": _int_or_none(usage.get("prompt_tokens")), "cached_tokens": _int_or_none(details.get("cached_tokens")), "endpoint_status": resp.status_code, } def generate(self, prompt: str, *, max_tokens: int | None = None) -> str: """POST *prompt* and return the raw response string. Thin wrapper over :meth:`generate_with_meta` that discards the metadata half of its return value — see that method for the full retry/error contract, which is identical here. """ raw, _meta = self.generate_with_meta(prompt, max_tokens=max_tokens) return raw def generate_with_meta( self, prompt: str, *, max_tokens: int | None = None, ) -> tuple[str, dict[str, Any]]: """POST *prompt*; return ``(raw_text, meta)``. *max_tokens* is a per-call ceiling on the completion length that replaces ``LLM_NUM_PREDICT`` for this request only; ``None`` -- what the SQL path always passes -- sends the setting unchanged. *meta* carries the raw response body (undiscarded) plus call-level facts the body itself cannot express: * ``"raw"`` — the full ``resp.json()`` dict, including ``usage`` (``prompt_tokens`` / ``completion_tokens``) and ``system_fingerprint`` where the endpoint returns one — exactly what ``observability/llm_status.py::build_llm_status`` expects as its ``raw`` argument. * ``"endpoint_status"`` — the HTTP status code of the attempt that produced this result. * ``"attempts"`` — which transport attempt (1-based) succeeded. * ``"total_ms"`` — wall-clock time of the successful attempt, in milliseconds, measured here (never inferred or guessed) since an OpenAI-compatible response carries no server-side timing of its own for :func:`~observability.llm_status.build_llm_status` to read. * ``"finish_reason"`` — the response's ``choices[0].finish_reason``, mapped onto this project's contract by :func:`_normalize_finish_reason`: ``"stop"``/``"length"`` pass through, ``"content_filter"``/``"tool_calls"`` are recognised too, and anything else is preserved as ``"other:"`` rather than being discarded. Never hardcoded — this is what lets a caller tell a truncated response (``"length"``) from a genuinely complete one (``"stop"``) instead of both reading as success. * ``"reasoning_detected"`` — ``True`` when the response appears to carry a model's reasoning/chain-of-thought text (see :func:`_extract_reasoning_text` and :func:`_content_carries_reasoning_markers`) rather than, or in addition to, a final answer. Detection only; ``raw`` is always ``content`` itself, never the reasoning text. * ``"reasoning_tokens"`` / ``"reasoning_tokens_estimated"`` — how many completion tokens were reasoning, and whether that figure is the server's own (``usage.completion_tokens_details.reasoning_tokens``) or an estimate from the reasoning text's length -- see :func:`_reasoning_token_stats`. ``None`` / ``False`` when the response shows no reasoning at all. * ``"ttft_ms"`` / ``"generation_ms"`` — only with ``LLM_STREAM_TIMINGS=true``: time to the first generated token (queue plus prefill) and the time from there to the end of the stream; ``ttft_ms + generation_ms == total_ms``. ``None`` for a non-streaming call, which cannot see inside its own wait. With ``LLM_STREAM_TIMINGS=true`` the request is sent with ``stream=true`` and ``stream_options={"include_usage": true}`` and the chunks are reassembled by :class:`_StreamAssembler` into the body a non-streaming call would have returned, so ``raw`` and every field above are derived from the same shape either way. The timeout then bounds the whole call (checked as chunks arrive) as well as the wait for each chunk. On the ``OUT_OF_SCOPE`` sentinel, *meta* is attached to the raised ``ValueError`` as an ``llm_meta`` attribute (rather than lost), since that is still a genuine, successful model response — just one the caller must not treat as a corrected-SQL attempt. Raises ------ ValueError("OUT_OF_SCOPE") Passed through from the model sentinel; carries ``.llm_meta``. requests.Timeout Propagated immediately — not retried. Caller maps to ModelTimeoutError. RuntimeError When the endpoint is unreachable, or every retry's response body was unparsable, after all retries. """ payload = self._build_payload(prompt) if max_tokens is not None: if max_tokens < 1: raise ValueError(f"max_tokens must be a positive integer, got {max_tokens!r}") # This request only: the next call is built from LLM_NUM_PREDICT again. payload["max_tokens"] = max_tokens streaming = bool(cfg.settings.llm_stream_timings) if streaming: payload["stream"] = True payload["stream_options"] = {"include_usage": True} last_exc: Exception | None = None for attempt in range(1, self._retries + 1): # perf_counter, not monotonic: monotonic() is GetTickCount64-backed # on Windows before Python 3.13 (~15.6ms resolution), which would # quantise -- or zero out -- total_ms for any fast response. start = time.perf_counter() try: ttft_ms: int | None = None if streaming: body, status_code, ttft_ms = self._post_streaming(payload, start) else: resp = requests.post( f"{self._base_url}/chat/completions", headers=self._headers, json=payload, timeout=self._timeout, ) resp.raise_for_status() body = resp.json() status_code = resp.status_code choice: dict[str, Any] = (body.get("choices") or [{}])[0] message: dict[str, Any] = choice.get("message") or {} raw: str = _answer_text(message).strip() logger.debug("OpenAI raw (attempt %d): %.300s", attempt, raw) # Reasoning-channel detection (see module docstring's note # on the gpt-oss deployment target and the helpers above): # never substituted for `raw` -- only ever used to flag the # response as suspect, in `meta`, so a downstream "No # SELECT / CTE found" rejection reads as a protocol # mismatch instead of the model looking incompetent at SQL. reasoning_text = _extract_reasoning_text(message) reasoning_detected = bool(reasoning_text) or _content_carries_reasoning_markers(raw) if reasoning_detected: logger.warning( "OpenAI response (attempt %d) appears to carry reasoning-channel " "text rather than a final answer; excerpt: %.200s", attempt, reasoning_text or raw, ) reasoning_tokens, reasoning_estimated = _reasoning_token_stats( body, reasoning_text, raw, ) total_ms = round((time.perf_counter() - start) * 1000) meta: dict[str, Any] = { "raw": body, "endpoint_status": status_code, "attempts": attempt, "total_ms": total_ms, "finish_reason": _normalize_finish_reason(choice.get("finish_reason")), "reasoning_detected": reasoning_detected, "reasoning_tokens": reasoning_tokens, "reasoning_tokens_estimated": reasoning_estimated, "ttft_ms": ttft_ms, "generation_ms": ( max(total_ms - ttft_ms, 0) if ttft_ms is not None else None ), } if raw.strip().upper() == _OUT_OF_SCOPE_SENTINEL: exc = ValueError(_OUT_OF_SCOPE_SENTINEL) exc.llm_meta = meta # type: ignore[attr-defined] raise exc return raw, meta except requests.Timeout: # Timeout is a hard failure -- propagate immediately, do not retry. raise except requests.RequestException as exc: # Mirrors the retired Ollama transport's discrimination: # requests.exceptions.JSONDecodeError subclasses BOTH # ValueError and RequestException, so a truncated/non-JSON # body from a flaky endpoint is caught here, not by a # `except ValueError` clause (there is none) that would # otherwise also catch -- and mask -- the OUT_OF_SCOPE # sentinel raised two lines above (a plain ValueError, not # a RequestException, so it never reaches this branch). last_exc = exc wait = _BACKOFF_BASE ** (attempt - 1) logger.warning( "OpenAI attempt %d/%d failed: %s -- retrying in %ds", attempt, self._retries, exc, wait, ) if attempt < self._retries: time.sleep(wait) raise RuntimeError( f"OpenAI-compatible endpoint {self._base_url!r} unreachable " f"after {self._retries} retries: {last_exc}" ) def generate_structured(self, segments: "PromptSegments", schema: dict) -> tuple[dict, dict[str, Any]]: """Constrained decoding via ``response_format: json_schema`` (strict mode).""" structured_payload: dict[str, Any] = { "model": self._model, "messages": [{"role": "user", "content": segments.flatten()}], "temperature": cfg.settings.llm_temperature, "top_p": cfg.settings.llm_top_p, "seed": cfg.settings.llm_seed, "response_format": { "type": "json_schema", "json_schema": {"name": "result", "schema": schema, "strict": True}, }, } # Same merge as the unstructured path. A constrained decode is # still a decode: a reasoning model asked for JSON reasons first # and can be cut off mid-object, so an operator who turned # reasoning off for one path has not turned it off at all unless # it applies here too. structured_payload.update(load_extra_body()) resp = requests.post( f"{self._base_url}/chat/completions", headers=self._headers, json=structured_payload, timeout=self._timeout, ) resp.raise_for_status() body = resp.json() choice: dict[str, Any] = (body.get("choices") or [{}])[0] text = _answer_text(choice.get("message")) # A schema-constrained decode can still be cut off by max_tokens # (a half-emitted JSON object) -- derive finish_reason the same way # generate_with_meta does rather than assuming "stop" here too, for # the same reason: json.loads(text) below would already raise on a # truncated body, but if a future caller catches that and falls # back to *meta*, it must not read "structured_output: True" next # to a finish_reason that silently claims a clean completion. meta = { "raw": body, "endpoint_status": resp.status_code, "attempts": 1, "structured_output": True, "finish_reason": _normalize_finish_reason(choice.get("finish_reason")), } return json.loads(text), meta def test_connection(self) -> bool: try: r = requests.get(f"{self._base_url}/models", headers=self._headers, timeout=5) return r.status_code == 200 except Exception: # noqa: BLE001 return False class MockBackend(LLMBackend): """Deterministic stub backend — no network, for CI / the router's own tests. Trusted by default (inherits :attr:`~llm.base.LLMBackend.trusted`'s ``True`` default): a stub with no real endpoint is not what the remote-provider governance gate exists to catch — see that property's docstring. """ def __init__(self, response: str = "SELECT 1", structured: dict | None = None) -> None: self._response = response self._structured = structured if structured is not None else {} @property def name(self) -> str: return "mock:stub" def generate(self, prompt: str) -> str: # noqa: ARG002 return self._response def generate_with_meta( self, prompt: str, *, max_tokens: int | None = None, # noqa: ARG002 ) -> tuple[str, dict[str, Any]]: return self._response, {"raw": {}, "endpoint_status": 200, "attempts": 1} def generate_structured(self, segments: "PromptSegments", schema: dict) -> tuple[dict, dict[str, Any]]: # noqa: ARG002 return dict(self._structured), {"raw": {}, "endpoint_status": 200, "attempts": 1, "structured_output": True} def test_connection(self) -> bool: return True