# 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