Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ OPENAI_API_KEY=
# OPENAI_EMBEDDING_MODEL=BAAI/bge-m3
# TEI rejects the ``dimensions=`` SDK kwarg — must be false when talking to TEI.
# OPENAI_EMBEDDING_SEND_DIMENSIONS=false

# -- Self-hosted chat LLM (opt-in) -------------------------------------------
# The chat path (dedup judge, enrichment, contradiction detection, entity
# extraction) talks to each provider's hosted URL by default. Each URL can
# be overridden, so ``ENTITY_EXTRACTION_PROVIDER=openai`` can point at any
# OpenAI-compatible server: LM Studio, vLLM, or a gateway. Set the matching
# API key to whatever the server expects (LM Studio ignores it) and name a
# loaded model in ``ENTITY_EXTRACTION_MODEL``. From inside a container,
# ``host.docker.internal`` reaches a server on the host.
#
# OPENAI_CHAT_BASE_URL=http://host.docker.internal:1234/v1
# ANTHROPIC_CHAT_BASE_URL=https://api.anthropic.com/v1
# OPENROUTER_CHAT_BASE_URL=https://openrouter.ai/api/v1
# Optional: only set when running an instruction-aware model (Qwen3-Embedding,
# e5-instruct). bge-m3 is symmetric — leave empty.
# EMBEDDING_QUERY_INSTRUCTION=
Expand Down
30 changes: 20 additions & 10 deletions common/llm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,30 @@
"GEMINI_DEFAULT_MODEL", "gemini-3.1-flash-lite-preview"
)

# OpenAI's chat-completions base URL — used by ``OpenAILLMProvider``
# (and for openrouter / anthropic compat where the API mirrors OpenAI's
# shape, with the base URL swapped via tenant-config override).
OPENAI_CHAT_BASE_URL = "https://api.openai.com/v1"
# Chat-completions base URLs. ``OpenAILLMProvider`` works against any
# OpenAI-compatible endpoint by varying ``base_url``; the registry picks
# the URL from the provider name (``ProviderName``). Each URL can be
# overridden from the environment, so a provider can talk to a self-hosted
# server (LM Studio, vLLM, a gateway) without a code change.
#
# ``OPENAI_HOSTED_CHAT_BASE_URL`` is the literal hosted endpoint. It stays
# separate from the overridable value so the provider can tell whether it
# is talking to api.openai.com, which accepts request shapes that other
# compatible servers reject.
OPENAI_HOSTED_CHAT_BASE_URL = "https://api.openai.com/v1"
OPENAI_CHAT_BASE_URL = os.environ.get(
"OPENAI_CHAT_BASE_URL", OPENAI_HOSTED_CHAT_BASE_URL
)

# Anthropic + OpenRouter base URLs and default models. The
# ``OpenAILLMProvider`` works against any of these endpoints by varying
# ``base_url``; the registry picks the right tuple based on
# ``ProviderName``.
ANTHROPIC_CHAT_BASE_URL = "https://api.anthropic.com/v1"
ANTHROPIC_CHAT_BASE_URL = os.environ.get(
"ANTHROPIC_CHAT_BASE_URL", "https://api.anthropic.com/v1"
)
ANTHROPIC_DEFAULT_MODEL = os.environ.get(
"ANTHROPIC_DEFAULT_MODEL", "claude-haiku-4-5-20251001"
) # Anthropic API requires native model IDs
OPENROUTER_CHAT_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_CHAT_BASE_URL = os.environ.get(
"OPENROUTER_CHAT_BASE_URL", "https://openrouter.ai/api/v1"
)
OPENROUTER_DEFAULT_MODEL = os.environ.get(
"OPENROUTER_DEFAULT_MODEL", "openai/gpt-5.4-nano"
)
Expand Down
225 changes: 204 additions & 21 deletions common/llm/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import json
import logging
import time
from urllib.parse import urlsplit

import httpx
import openai
Expand All @@ -27,6 +28,7 @@
LLM_JSON_MAX_OUTPUT_TOKENS,
LLM_PROVIDER_MAX_RETRIES,
OPENAI_CHAT_BASE_URL,
OPENAI_HOSTED_CHAT_BASE_URL,
OPENAI_HTTPX_CONNECT_TIMEOUT_SECONDS,
OPENAI_HTTPX_MAX_CONNECTIONS,
OPENAI_HTTPX_MAX_KEEPALIVE_CONNECTIONS,
Expand All @@ -35,6 +37,7 @@
)
from common.llm.providers._shape_error import ProviderResponseShapeError
from common.llm.providers._truncation import raise_if_truncated
from common.provider_names import ProviderName

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -88,6 +91,157 @@ def _int(value: object) -> int:
)


_HOSTED_OPENAI_HOST = urlsplit(OPENAI_HOSTED_CHAT_BASE_URL).hostname or ""
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})


def _is_hosted_openai(base_url: str) -> bool:
"""True when ``base_url`` points at api.openai.com, whatever the scheme.

The decision is by host, not by string: ``http://api.openai.com/v1`` or
a trailing slash still count as hosted. A proxy or regional alias on
another host does not.
"""
return (urlsplit(base_url).hostname or "").lower() == _HOSTED_OPENAI_HOST


def _is_plaintext_off_host(base_url: str) -> bool:
"""True when ``base_url`` is plain ``http`` to a host that is not loopback."""
parts = urlsplit(base_url)
return (
parts.scheme == "http" and (parts.hostname or "").lower() not in _LOOPBACK_HOSTS
)


def _allows_null(schema: dict) -> bool:
"""True when ``schema`` already admits ``null``."""
t = schema.get("type")
if t == "null" or (isinstance(t, list) and "null" in t):
return True
return any(
isinstance(opt, dict) and opt.get("type") == "null"
for opt in schema.get("anyOf", []) + schema.get("oneOf", [])
)


def _strict_schema(schema: dict) -> dict:
"""Return a copy of ``schema`` that strict JSON mode accepts.

Strict mode, as OpenAI defines it and as Anthropic's OpenAI-compatible
endpoint enforces it, wants every object closed
(``additionalProperties: false``) with every property listed under
``required``. Pydantic-generated schemas leave both open for fields
with defaults. This walks the schema (``properties``, ``items``,
``anyOf``/``oneOf``/``allOf``, ``$defs``) and closes each object.

Marking a property required changes what the model must emit, so a
property the source schema left optional is made nullable as well:
the model answers ``null`` where it would have omitted the key, and
``_drop_optional_nulls`` removes those keys after parsing, so callers
see the same absent-or-present shape they saw before. A property that
already admits ``null`` is left as it is.

``default`` and ``title`` are dropped: strict mode rejects or ignores
them and they carry no meaning for the model. The input is not
modified.
"""
if isinstance(schema, list):
return [_strict_schema(s) for s in schema] # type: ignore[return-value]
if not isinstance(schema, dict):
return schema
out: dict = {}
for key, value in schema.items():
if key in ("default", "title"):
continue
if key in ("properties", "$defs"):
out[key] = {k: _strict_schema(v) for k, v in value.items()}
elif key in ("items", "anyOf", "oneOf", "allOf"):
out[key] = _strict_schema(value)
else:
out[key] = value
if out.get("type") == "object" or "properties" in out:
was_required = set(schema.get("required", []))
props = out.get("properties", {})
for name, prop in props.items():
if (
name not in was_required
and isinstance(prop, dict)
and not _allows_null(prop)
):
props[name] = {"anyOf": [prop, {"type": "null"}]}
out["additionalProperties"] = False
out["required"] = list(props.keys())
return out


def _resolve_ref(schema: dict, root: dict) -> dict:
"""Follow a local ``$ref`` into ``root["$defs"]``; other schemas pass through."""
ref = schema.get("$ref", "")
if ref.startswith("#/$defs/"):
return root.get("$defs", {}).get(ref[len("#/$defs/") :], {})
return schema


def _drop_optional_nulls(value, schema: dict, root: dict | None = None):
"""Remove ``null`` values for keys the source schema left optional.

The counterpart of ``_strict_schema``: it walks the parsed reply with
the *original* schema and deletes a key whose value is ``null`` when
that key was not in the object's ``required`` list. A ``null`` for a
required key, or for a key the source schema made nullable itself, is
kept as the model sent it. Arrays and local ``$ref`` definitions are
followed; other shapes are returned unchanged.
"""
root = schema if root is None else root
schema = _resolve_ref(schema, root)
if isinstance(value, dict) and isinstance(schema.get("properties"), dict):
required = set(schema.get("required", []))
props = schema["properties"]
out = {}
for key, item in value.items():
prop = props.get(key)
if (
item is None
and prop is not None
and key not in required
and not _allows_null(prop)
):
continue
out[key] = (
_drop_optional_nulls(item, prop, root)
if isinstance(prop, dict)
else item
)
return out
if isinstance(value, list) and isinstance(schema.get("items"), dict):
return [_drop_optional_nulls(item, schema["items"], root) for item in value]
for option in schema.get("anyOf", []) + schema.get("oneOf", []):
if isinstance(option, dict) and (
option.get("properties") or option.get("items") or "$ref" in option
):
return _drop_optional_nulls(value, option, root)
return value


def _strip_code_fence(content: str) -> str:
"""Remove a Markdown code fence around ``content`` when there is one.

Without a ``response_format`` to constrain them, models often answer
with the JSON wrapped in a fence. The JSON inside is what the caller
asked for.
"""
text = content.strip()
if not text.startswith("```"):
return content
first_newline = text.find("\n")
if first_newline == -1:
return content
body = text[first_newline + 1 :]
if body.rstrip().endswith("```"):
body = body.rstrip()[:-3]
return body


class OpenAILLMProvider:
"""LLM provider using the OpenAI chat completions API.

Expand All @@ -107,6 +261,16 @@ def __init__(
self._model = model
self._base_url = base_url
self._provider_name = provider_name
if _is_plaintext_off_host(base_url):
# Operator configuration, not caller input, so this is a warning
# and not a refusal: a plain-http base URL off the loopback sends
# the provider key in the clear on every call.
logger.warning(
"%s chat base URL %s is plain http to a non-loopback host; "
"the API key is sent unencrypted",
provider_name,
base_url,
)
# Explicit per-call timeout — without this the SDK rides httpx's
# default and a single hung upstream call would eat the whole
# enrichment budget silently.
Expand Down Expand Up @@ -193,9 +357,20 @@ async def complete_json(
) -> dict:
"""Send a prompt and return a parsed JSON dict.

Without ``response_schema``, uses
``response_format={"type": "json_object"}`` to enforce shape-less
JSON output (back-compat for enrichment and dedup callers).
The ``response_format`` sent depends on the endpoint, because the
compatible servers do not agree on what they accept:

- Hosted OpenAI (``OPENAI_HOSTED_CHAT_BASE_URL``) and OpenRouter keep
the shapes they previously received: ``json_object`` without a
schema, and a non-strict ``json_schema`` with one.
- Any other base URL (a self-hosted server or Anthropic's compatible
endpoint) gets no ``response_format`` without a
schema, because LM Studio and Anthropic both reject
``json_object``; the prompt already asks for JSON and a code
fence in the reply is stripped before parsing. With a schema it
gets ``strict: true`` and a closed schema (see
``_strict_schema``), which is the one shape Anthropic accepts and
which every other compatible server also takes.

``seed`` (A5a #2): when provided, forwarded to OpenAI's chat
completions API for response determinism. ``temperature=0.0`` is
Expand All @@ -208,11 +383,10 @@ async def complete_json(

``response_schema`` (A5b #3): when provided, switches to
``response_format={"type": "json_schema", ...}`` so the API
enforces the output shape server-side. ``strict=False`` —
Pydantic-generated schemas don't always satisfy OpenAI's strict-
mode requirements (additionalProperties=false everywhere); the
client-side Pydantic parse is the real guardrail. Passing
``None`` preserves today's shape-less behaviour.
enforces the output shape server-side. Hosted OpenAI and OpenRouter
receive the source schema with ``strict=False``. Other endpoints
receive a closed schema from ``_strict_schema`` with ``strict=True``.
Passing ``None`` preserves today's shape-less behaviour.

``reasoning_effort`` (E3): forwarded to the chat completions API
only when set, so callers doing bounded classification work (the
Expand All @@ -226,27 +400,34 @@ async def complete_json(
see the inline note at the call.
"""
t0 = time.perf_counter()
if response_schema is not None:
response_format: dict = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": response_schema,
"strict": False,
},
}
else:
response_format = {"type": "json_object"}
uses_openai_format = (
self._provider_name == ProviderName.OPENROUTER
or _is_hosted_openai(self._base_url)
)
create_kwargs: dict = {
"model": self._model,
"messages": [{"role": "user", "content": prompt}],
"response_format": response_format,
"temperature": temperature,
# Runaway guard — same failure mode as the Gemini-backed
# providers: an uncapped looping generation comes back as
# truncated JSON (finish_reason="length").
"max_completion_tokens": LLM_JSON_MAX_OUTPUT_TOKENS,
}
if response_schema is not None:
create_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": (
response_schema
if uses_openai_format
else _strict_schema(response_schema)
),
"strict": not uses_openai_format,
},
}
elif uses_openai_format:
create_kwargs["response_format"] = {"type": "json_object"}
if seed is not None:
create_kwargs["seed"] = seed
if reasoning_effort is not None:
Expand Down Expand Up @@ -281,9 +462,11 @@ async def complete_json(
model=self._model,
max_tokens=LLM_JSON_MAX_OUTPUT_TOKENS,
)
parsed = json.loads(content)
parsed = json.loads(_strip_code_fence(content))
if not isinstance(parsed, dict):
raise OpenAIResponseShapeError(content, type(parsed).__name__)
if response_schema is not None and not uses_openai_format:
parsed = _drop_optional_nulls(parsed, response_schema)
return parsed

async def complete_text(
Expand Down
Loading
Loading